mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7cd7f66cc | ||
|
|
8d4ef01621 | ||
|
|
335e4ca56f | ||
|
|
e327f93711 | ||
|
|
a978a1e010 | ||
|
|
e561431f7c |
@@ -592,8 +592,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1078,9 +1078,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2221,27 +2221,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Xjo7b+y3TG0fIoaiQ86tV98MGofO52aMGMBMlvDCNs0=",
|
||||
"aarch64-linux": "sha256-9F6LHNOfzkyakz3GScvuzIsy0vCaQRuTZRdFSYHLanY=",
|
||||
"aarch64-darwin": "sha256-U7TihrGM9l2vSU7Z6K2KinFvKQ5fBJyK4zXoDyZFChI=",
|
||||
"x86_64-darwin": "sha256-JgenSPWgdxPjiHQiblhIM/+AL3oxrPm9zk18Fci19kk="
|
||||
"x86_64-linux": "sha256-PhCLeam/Mx2KL0bu0kg1+TJ46CpEOmnkQQiK3myGqnk=",
|
||||
"aarch64-linux": "sha256-UJ2pj9WQ3hYOJBw0rFwcDx4XrvZf3CIF+hK0A9FjNbc=",
|
||||
"aarch64-darwin": "sha256-WiVvvLEpeWe/LMIgZUBJSkY2jfoxgDmHyddE9kp2Dyg=",
|
||||
"x86_64-darwin": "sha256-67n45FB0BdM30hcipPZqgjPZkfKc1dK56z9kjSLIeFM="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,9 +49,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.9",
|
||||
"@opentui/keymap": "0.5.9",
|
||||
"@opentui/solid": "0.5.9",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { afterEach, expect, test, vi } from "bun:test"
|
||||
import { createAnimatedPresence } from "../src/runtime/animated-presence"
|
||||
import { batch, createRoot, createSignal } from "solid-js"
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
test("animates visibility changes without animating initial presence", () => {
|
||||
createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string | undefined>("steer")
|
||||
@@ -66,3 +68,41 @@ test("does not animate visibility changes across identities", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("holds brief appearances until the minimum duration expires", () => {
|
||||
vi.useFakeTimers()
|
||||
createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string>()
|
||||
const presence = createAnimatedPresence(value, () => null, undefined, 1000)
|
||||
|
||||
setValue("shell")
|
||||
vi.advanceTimersByTime(100)
|
||||
setValue(undefined)
|
||||
expect(presence.show()).toBe(true)
|
||||
expect(presence.value()).toBe("shell")
|
||||
|
||||
vi.advanceTimersByTime(899)
|
||||
expect(presence.show()).toBe(true)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(presence.show()).toBe(false)
|
||||
expect(presence.animate()).toBe(true)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("clears held appearances when identity changes", () => {
|
||||
vi.useFakeTimers()
|
||||
createRoot((dispose) => {
|
||||
const [identity, setIdentity] = createSignal("session-a")
|
||||
const [value, setValue] = createSignal<string>("shell")
|
||||
const presence = createAnimatedPresence(value, () => null, identity, 1000)
|
||||
|
||||
setValue(undefined)
|
||||
expect(presence.show()).toBe(true)
|
||||
setIdentity("session-b")
|
||||
expect(presence.show()).toBe(false)
|
||||
expect(presence.animate()).toBe(false)
|
||||
expect(presence.value()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
|
||||
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
||||
pattern: Schema.String,
|
||||
path: Schema.optionalKey(RelativePath),
|
||||
hidden: Schema.optionalKey(Schema.Boolean),
|
||||
limit: Schema.optionalKey(PositiveInt),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -65,8 +65,27 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return structuredClone(data).filter(
|
||||
return copy(data).filter(
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
}
|
||||
|
||||
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
|
||||
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
|
||||
// magnitude faster than structuredClone's general graph walk on the startup path.
|
||||
function copy<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map(copy) as T
|
||||
if (value !== null && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(value)) {
|
||||
const copied = copy((value as Record<string, unknown>)[key])
|
||||
// Assigning this key would set the prototype rather than an own property, unlike structuredClone.
|
||||
if (key === "__proto__")
|
||||
Object.defineProperty(result, key, { value: copied, enumerable: true, writable: true, configurable: true })
|
||||
else result[key] = copied
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -80,13 +80,26 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
const ordered = [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
]
|
||||
// Registry activation dies on a duplicate ID, which would drop the whole generation including builtins.
|
||||
// Keep the first occurrence in boot order and report later ones like any other plugin setup failure.
|
||||
const duplicate = (plugin: Plugin.Generation, index: number) =>
|
||||
ordered.findIndex((other) => other.id === plugin.id) !== index
|
||||
return {
|
||||
plugins: [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
|
||||
failures: [
|
||||
...failures.values(),
|
||||
...ordered.filter(duplicate).map((plugin) => ({
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" as const },
|
||||
state: { status: "failed" as const, error: `Duplicate plugin ID: ${plugin.id}` },
|
||||
features: { server: true as const, ...plugin.features },
|
||||
})),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
pending: [...pending],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,9 @@ export const Input = Schema.Struct({
|
||||
path: Schema.optionalKey(RelativePath).annotate({
|
||||
description: "Directory to search. Defaults to the current working directory.",
|
||||
}),
|
||||
hidden: FileSystem.GlobInput.fields.hidden.annotate({
|
||||
description: "Include hidden files and directories (default: false).",
|
||||
}),
|
||||
limit: FileSystem.GlobInput.fields.limit.annotate({
|
||||
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
|
||||
}),
|
||||
@@ -76,6 +79,7 @@ export const Plugin = {
|
||||
metadata: {
|
||||
root: searchPath ?? ".",
|
||||
path: searchPath,
|
||||
hidden: input.hidden,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
@@ -97,6 +101,7 @@ export const Plugin = {
|
||||
.glob({
|
||||
cwd: root,
|
||||
pattern: input.pattern,
|
||||
hidden: input.hidden,
|
||||
limit: limit + 1,
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -411,6 +411,60 @@ describe("ModelsDevPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("copies model request bodies without reinterpreting literal __proto__ keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("gpt-5.4")
|
||||
// A JSON body may legitimately contain a "__proto__" key; both copy stages must keep it as an own property.
|
||||
const body = JSON.parse('{"__proto__":{"service_tier":"priority"},"keep":true}') as Record<string, unknown>
|
||||
const snapshot = {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, output: 128_000 },
|
||||
body,
|
||||
},
|
||||
],
|
||||
} satisfies ModelsDev.Snapshot
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({ get: () => Effect.succeed([snapshot]), refresh: () => Effect.void }),
|
||||
),
|
||||
)
|
||||
|
||||
const copied = (yield* catalog.model.get(providerID, modelID))?.body
|
||||
expect(copied).not.toBe(body)
|
||||
expect(Object.hasOwn(copied ?? {}, "__proto__")).toBe(true)
|
||||
expect(Object.keys(copied ?? {})).toEqual(["__proto__", "keep"])
|
||||
expect(JSON.stringify(copied)).toBe(JSON.stringify(body))
|
||||
expect(copied).not.toHaveProperty("service_tier")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits legacy provider aliases", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const id = Plugin.ID.make("account-prompts")
|
||||
|
||||
// Host and instance plugins share one ID; each registers a distinct command so the winner is observable.
|
||||
const greeter = (command: string) =>
|
||||
define({
|
||||
id,
|
||||
effect: (ctx) =>
|
||||
ctx.command.transform((draft) => draft.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const map = yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Instance.layer(ref, { discovery: false, plugins: [greeter("instance-greet")], replacements: bindings }),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
provide: (session) => Effect.provide(map.get(session.location)),
|
||||
}),
|
||||
),
|
||||
]
|
||||
return map
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor", () => {
|
||||
it.live("reports a duplicate plugin ID as a failure without dropping the generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(greeter("host-greet"))
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const state = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const commands = yield* Command.Service
|
||||
return {
|
||||
inventory: yield* plugins.list(),
|
||||
host: yield* commands.get("host-greet"),
|
||||
instance: yield* commands.get("instance-greet"),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
|
||||
// Earlier boot order wins: the host SDK plugin activates and the instance copy is reported.
|
||||
expect(state.inventory.filter((plugin) => plugin.id === id)).toEqual([
|
||||
{ id, source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id,
|
||||
source: { type: "sdk" },
|
||||
state: { status: "failed", error: `Duplicate plugin ID: ${id}` },
|
||||
features: { server: true },
|
||||
},
|
||||
])
|
||||
expect(state.host).toBeDefined()
|
||||
expect(state.instance).toBeUndefined()
|
||||
// Builtins stay active: the duplicate is a plugin failure, not a generation defect.
|
||||
expect(
|
||||
state.inventory.some((plugin) => plugin.id?.startsWith("opencode.") && plugin.state.status === "active"),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -29,8 +29,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.9",
|
||||
"@opentui/solid": ">=0.5.9",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -62,7 +62,7 @@ await opencode.sessions.prompt({ sessionID: session.id, text: "Review the change
|
||||
|
||||
- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key.
|
||||
- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime.
|
||||
- Session metadata, message, inbox, and context reads do not initialize plugins; permission and form lists read instance services and therefore acquire the Session's instance. Configuration failure, an instance plugin ID that collides with a host `plugins` entry, or initial setup failure of a supplied plugin prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
|
||||
- Session metadata, message, inbox, and context reads do not initialize plugins; permission and form lists read instance services and therefore acquire the Session's instance. Configuration failure or a supplied plugin reported as failed after activation, including initial setup failure or an ID that collides with a host `plugins` entry (the host plugin keeps the ID), prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
|
||||
- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved.
|
||||
- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope.
|
||||
- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it.
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import type { InstancePlugins } from "@opencode-ai/core/plugin/instance"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -27,7 +26,7 @@ export function node(options: Options, replacements: () => LayerNode.Replacement
|
||||
return makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
layer: layer(options, replacements),
|
||||
deps: [LocationServiceMap.node, SdkPlugins.node],
|
||||
deps: [LocationServiceMap.node],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +36,6 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Effect.scope
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const key = (session: Session.Info) => ({
|
||||
key: options.key(session),
|
||||
...LocationServiceMap.canonical(session.location),
|
||||
@@ -48,17 +46,6 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const configuration = yield* options.configure(input.key).pipe(Effect.orDie)
|
||||
// A host/instance ID collision fails the whole plugin generation, which leaves no inventory
|
||||
// trace to check after activation. Reject it before constructing anything.
|
||||
const collisions = configuration.plugins.filter((plugin) =>
|
||||
sdk.all().some((host) => host.id === plugin.id),
|
||||
)
|
||||
if (collisions.length > 0)
|
||||
yield* Effect.die(
|
||||
new Error(
|
||||
`Instance plugin IDs collide with host plugins: ${collisions.map((plugin) => plugin.id).join(", ")}`,
|
||||
),
|
||||
)
|
||||
return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), {
|
||||
plugins: configuration.plugins,
|
||||
replacements: [
|
||||
@@ -73,6 +60,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
// Covers setup failures and IDs colliding with host plugins; Core reports both in the inventory.
|
||||
const failed = (yield* plugins.list()).filter(
|
||||
(plugin) =>
|
||||
plugin.state.status === "failed" &&
|
||||
|
||||
@@ -298,6 +298,7 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
|
||||
await using directory = await tmpdir("opencode-promise-instance-collision-")
|
||||
const configured: string[] = []
|
||||
const setups: string[] = []
|
||||
const cleanups: string[] = []
|
||||
await using opencode = await OpenCode.create({
|
||||
...hostOptions(directory.path),
|
||||
plugins: [
|
||||
@@ -305,6 +306,9 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
|
||||
id: "account-prompts",
|
||||
setup() {
|
||||
setups.push("host")
|
||||
return () => {
|
||||
cleanups.push("host")
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -342,12 +346,17 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([])
|
||||
expect((await opencode.message.list({ sessionID: session.id })).data).toEqual([])
|
||||
expect(configured).toEqual(["alpha"])
|
||||
expect(setups).toEqual([])
|
||||
// The host plugin wins the ID and activates inside the instance before the colliding instance plugin
|
||||
// is reported failed; rejecting the instance tears that generation down again.
|
||||
expect(setups).toEqual(["host"])
|
||||
expect(cleanups).toEqual(["host"])
|
||||
|
||||
const admitted = await opencode.sessions.prompt(input)
|
||||
expect(admitted.id).toBe(input.id)
|
||||
expect(admitted.payload.text).toBe("alpha: Retry this input")
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([admitted])
|
||||
expect(configured).toEqual(["alpha", "alpha"])
|
||||
expect(setups).toEqual(["host", "alpha"])
|
||||
// The reconstructed instance runs a fresh generation: host plugin again, then the renamed instance plugin.
|
||||
expect(setups).toEqual(["host", "host", "alpha"])
|
||||
expect(cleanups).toEqual(["host"])
|
||||
}, 20_000)
|
||||
|
||||
Reference in New Issue
Block a user