mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 18:34:42 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a366c3cab | |||
| c992716523 | |||
| e402600d92 | |||
| 1e4e9c5d85 | |||
| f34b74b1dc | |||
| b257d476c9 | |||
| 5e38101fba | |||
| 4beaffbda9 | |||
| 21bbcc33a1 | |||
| 0b1dbaf621 | |||
| 0da51e9274 | |||
| dea1f9cb7b | |||
| 969055918f | |||
| f269025416 | |||
| 4643cacb4a | |||
| 68a40e2d21 | |||
| 57c02cd04b | |||
| 46515df4a3 | |||
| 51250e4346 | |||
| dbd47702b2 | |||
| a085bf62a4 | |||
| b605f355ca | |||
| 85e2b0a23a | |||
| 3dc187bf3f | |||
| ed3259a9b7 | |||
| db09cc842e |
@@ -47,7 +47,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2' || github.ref_name == 'beta'
|
||||
if: github.ref_name == 'v2'
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
|
||||
@@ -575,6 +575,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -585,6 +586,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -971,7 +973,6 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1123,7 +1124,6 @@
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.1",
|
||||
"resolve.exports": "2.0.3",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "4.4.3",
|
||||
"solid-js": "1.9.15",
|
||||
@@ -5171,8 +5171,6 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
|
||||
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
|
||||
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
|
||||
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
@@ -93,7 +92,6 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.4.3",
|
||||
"solid-list": "0.3.0",
|
||||
|
||||
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("new session tab hugs its content", async ({ page }) => {
|
||||
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "draft", server, directory, draftID: "draft_tab_width" },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, directory: sessionA.directory },
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
|
||||
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
|
||||
await expect(draftTab).toContainText("New session")
|
||||
const width = (await draftTab.boundingBox())?.width ?? 0
|
||||
expect(width).toBeGreaterThan(100)
|
||||
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
|
||||
await testInfo.attach("new-session-between-tabs", {
|
||||
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
tabs.evaluateAll((tabs) => {
|
||||
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
|
||||
"tab-project",
|
||||
])
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeProjectInfo", () => {
|
||||
test("keeps the project VCS backend", () => {
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
|
||||
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryKey", () => {
|
||||
test("normalizes slashes", () => {
|
||||
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
|
||||
|
||||
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
|
||||
...project,
|
||||
worktree,
|
||||
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
|
||||
const options = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = input.session.project()
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs) list.push("git")
|
||||
if (
|
||||
project?.vcs === "git" &&
|
||||
project?.vcs &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
|
||||
const value = vcsMode()
|
||||
return {
|
||||
queryKey: [...vcsKey(), value] as const,
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: value
|
||||
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
|
||||
})
|
||||
const detailsQuery = createQuery(() => ({
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
|
||||
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
|
||||
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
|
||||
open,
|
||||
openFile,
|
||||
options,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs === "git" ? review.details.diffs() : []}
|
||||
diffs={project().vcs ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
|
||||
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
|
||||
classList={{
|
||||
invisible: props.hidden,
|
||||
"w-full [container-type:inline-size]": props.orientation === "vertical",
|
||||
}}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
@@ -432,16 +429,14 @@ export function DraftTabItem(props: {
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Icon name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
data-orientation={props.orientation}
|
||||
class="relative flex"
|
||||
classList={{
|
||||
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
|
||||
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
|
||||
"w-full shrink-0": props.orientation === "vertical",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -128,7 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
bytecode: true,
|
||||
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
|
||||
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
|
||||
bytecode: false,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
@@ -146,6 +148,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_ARTIFACT: `'cli'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||
|
||||
@@ -23,7 +23,13 @@ async function publish(dir: string, name: string, version: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||
async function publishDistribution(input: {
|
||||
root: string
|
||||
name: string
|
||||
binary: string
|
||||
packagePrefix: string
|
||||
artifact: string
|
||||
}) {
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
|
||||
const item = await Bun.file(`${input.root}/${filepath}`).json()
|
||||
@@ -72,6 +78,13 @@ async function publishDistribution(input: { root: string; name: string; binary:
|
||||
),
|
||||
)
|
||||
await publish(`${input.root}/${input.name}`, input.name, version)
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: input.artifact,
|
||||
distribution: "npm",
|
||||
version,
|
||||
metadata: { package: input.name },
|
||||
})
|
||||
}
|
||||
|
||||
await publishDistribution({
|
||||
@@ -79,6 +92,7 @@ await publishDistribution({
|
||||
name: pkg.name,
|
||||
binary: "opencode2",
|
||||
packagePrefix: "@opencode-ai/cli-",
|
||||
artifact: "cli",
|
||||
})
|
||||
if (existsSync("./dist/node")) {
|
||||
await publishDistribution({
|
||||
@@ -86,12 +100,6 @@ if (existsSync("./dist/node")) {
|
||||
name: "opencode-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
artifact: "cli-node",
|
||||
})
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "npm",
|
||||
version: Script.version,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -93,7 +93,11 @@ try {
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
// Windows can retain directory handles briefly after the service processes exit.
|
||||
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
|
||||
console.error("Failed to remove service smoke-test directory", cause)
|
||||
failure ??= cause
|
||||
})
|
||||
if (failure)
|
||||
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
|
||||
cause: failure,
|
||||
|
||||
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
@@ -16,9 +17,9 @@ export default Runtime.handler(
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
const installed = yield* npm.add(input.package)
|
||||
const entrypoints = Host.resolve(installed)
|
||||
const target = configurationTarget(entrypoints.server, entrypoints.tui)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const installed = yield* npm.resolve(target)
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
|
||||
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -22,27 +23,27 @@ export default Runtime.handler(
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (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 (local) {
|
||||
const directory = fileURLToPath(local)
|
||||
const entrypoints = Host.resolve({ directory })
|
||||
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
|
||||
}
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
const installed = yield* npm.resolve(target)
|
||||
if (!Host.resolve(installed).tui) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
const output = format(response.data, configured.flat(), input.builtin)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
@@ -81,9 +82,7 @@ export function format(
|
||||
? 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))),
|
||||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default Runtime.handler(
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
: npm.update(item.target).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
|
||||
@@ -2,21 +2,16 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
@@ -180,6 +175,15 @@ const make = Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
const directory = path.dirname(path.dirname(executable))
|
||||
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
|
||||
.readFileString(path.join(directory, "package.json"))
|
||||
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
|
||||
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
|
||||
return manifest.name
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
@@ -216,10 +220,11 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode2.exe" : "opencode2",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
@@ -228,35 +233,49 @@ const make = Effect.gen(function* () {
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}),
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release()).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
"install",
|
||||
"--global",
|
||||
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
|
||||
target,
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
declare const OPENCODE_VERSION: string
|
||||
declare const OPENCODE_CHANNEL: string
|
||||
declare const OPENCODE_ARTIFACT: string
|
||||
|
||||
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
|
||||
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
|
||||
export const OPENCODE_LOCAL = channel === "local"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { expect, test } from "bun:test"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -17,11 +17,29 @@ function fixture(
|
||||
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
name = "@opencode-ai/cli",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const executable = path.join(root, "package", "bin", "opencode")
|
||||
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
path.join(root, "package", "package.json"),
|
||||
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
|
||||
)
|
||||
// The updater uses global fetch; scope this replacement to each install test.
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(request) => Effect.sync(() => request.mockRestore()),
|
||||
)
|
||||
const global = Global.make({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
@@ -37,6 +55,10 @@ function fixture(
|
||||
const updater = yield* Updater.Service.pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
AppProcess.Service,
|
||||
AppProcess.Service.of({
|
||||
@@ -214,9 +236,12 @@ test("Node distribution honors the compile-time CLI name", async () => {
|
||||
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
|
||||
it.live("Node distribution resolves the published npm package", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}))
|
||||
const test = yield* fixture(
|
||||
(command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}),
|
||||
"opencode-node",
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
|
||||
@@ -268,6 +268,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_VERSION: JSON.stringify(input.version),
|
||||
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
|
||||
@@ -985,15 +985,6 @@ export type ReferenceUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2299,7 +2290,6 @@ export type V2Event =
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
|
||||
+218
-427
@@ -83,10 +83,8 @@ type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: {
|
||||
server?: McpServer[]
|
||||
resource?: McpResource[]
|
||||
}
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderInfo[]
|
||||
reference?: ReferenceInfo[]
|
||||
@@ -109,7 +107,6 @@ type Store = {
|
||||
messageCursor: Record<string, string | undefined>
|
||||
messageLoading: Record<string, boolean>
|
||||
pending: Record<string, SessionInboxInfo[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormWithLocation[]>
|
||||
@@ -125,8 +122,8 @@ export function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
function locationQuery(ref: LocationRef) {
|
||||
return { directory: ref.directory, workspace: ref.workspaceID }
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
@@ -200,7 +197,6 @@ export function createData(config: CreateDataInput) {
|
||||
messageCursor: {},
|
||||
messageLoading: {},
|
||||
pending: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
},
|
||||
@@ -233,13 +229,6 @@ export function createData(config: CreateDataInput) {
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
|
||||
)
|
||||
if (store.session.input[sessionID]?.includes(inboxID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
@@ -337,8 +326,8 @@ export function createData(config: CreateDataInput) {
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// Upsert an admitted inbox item into pending and (for user and synthetic
|
||||
// items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
@@ -353,8 +342,6 @@ export function createData(config: CreateDataInput) {
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
})
|
||||
}
|
||||
@@ -403,15 +390,44 @@ export function createData(config: CreateDataInput) {
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
insert(sessionID: string, item: SessionMessageInfo) {
|
||||
message.update(sessionID, (draft, index) => message.append(draft, index, item))
|
||||
},
|
||||
// Streaming events target one assistant message and, within it, the latest part of a kind.
|
||||
// A missing target means the row was never loaded or was evicted; the event is dropped.
|
||||
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
|
||||
message.update(sessionID, (draft, index) => {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : draft[position]
|
||||
if (item?.type === "assistant") fn(item)
|
||||
})
|
||||
},
|
||||
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const tool = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
|
||||
)
|
||||
if (tool) fn(tool)
|
||||
})
|
||||
},
|
||||
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
if (text) fn(text)
|
||||
})
|
||||
},
|
||||
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const reasoning = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
if (reasoning) fn(reasoning)
|
||||
})
|
||||
},
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : messages[position]
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
shell(messages: SessionMessageInfo[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
@@ -420,19 +436,6 @@ export function createData(config: CreateDataInput) {
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
@@ -510,12 +513,8 @@ export function createData(config: CreateDataInput) {
|
||||
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)
|
||||
}
|
||||
if (pending.length) draft.pending[sessionID] = pending
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -539,7 +538,6 @@ export function createData(config: CreateDataInput) {
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.form[sessionID]
|
||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||
@@ -611,14 +609,12 @@ export function createData(config: CreateDataInput) {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -626,13 +622,11 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
if (!store.session.message[event.data.sessionID]) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
@@ -667,16 +661,14 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "info", event.data.sessionID, "location", event.data.location)
|
||||
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -706,7 +698,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
|
||||
removePending(event.data.sessionID, event.data.inboxID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inboxID)
|
||||
@@ -750,42 +742,36 @@ export function createData(config: CreateDataInput) {
|
||||
// and produce no transcript message.
|
||||
const updateText = event.data.text
|
||||
if (updateText === undefined) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.synthetic":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.ended":
|
||||
@@ -800,9 +786,8 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.message.content.updated": {
|
||||
if (store.session.message[event.data.sessionID])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const assistant = message.assistant(draft, index, event.data.messageID)
|
||||
if (assistant) assistant.content = [...event.data.content]
|
||||
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
|
||||
assistant.content = [...event.data.content]
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
@@ -844,65 +829,54 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.step.streamed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (currentAssistant) currentAssistant.time.streamed = event.created
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.streamed = event.created
|
||||
})
|
||||
return
|
||||
case "session.step.ended": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.step.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish ?? "error"
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish ?? "error"
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.error = event.data.error
|
||||
assistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
}
|
||||
})
|
||||
return
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({ type: "text", text: "" })
|
||||
})
|
||||
return
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
@@ -912,86 +886,60 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
tool.time.ran = event.created
|
||||
tool.executed = event.data.executed
|
||||
tool.providerState = event.data.state
|
||||
tool.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
})
|
||||
return
|
||||
case "session.tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.metadata = event.data.metadata
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
|
||||
})
|
||||
return
|
||||
case "session.tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
input: tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: [...event.data.content],
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
@@ -1000,30 +948,20 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text = event.data.text
|
||||
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) reasoning.state = event.data.state
|
||||
})
|
||||
return
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: event.data.at,
|
||||
error: event.data.error,
|
||||
}
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
|
||||
})
|
||||
return
|
||||
case "session.execution.started":
|
||||
@@ -1031,16 +969,14 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.compaction.started":
|
||||
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
@@ -1075,11 +1011,12 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
@@ -1273,6 +1210,41 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
|
||||
// effective location, and publishes under the server's canonical location; `alias`
|
||||
// also publishes under the requested key when the two differ.
|
||||
function locationResource<Field extends keyof LocationData>(
|
||||
field: Field,
|
||||
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
|
||||
options?: { alias?: boolean },
|
||||
) {
|
||||
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
|
||||
return {
|
||||
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
|
||||
sync: (ref?: LocationRef) => {
|
||||
const location = ref ?? defaultLocation()
|
||||
const id = locationKey(location)
|
||||
return sync.run(`location.${field}:${id}`, async () => {
|
||||
const response = await load(locationQuery(location))
|
||||
const key = locationKey(response.location)
|
||||
publish(key, response.data)
|
||||
if (options?.alias && key !== id) publish(id, response.data)
|
||||
})
|
||||
},
|
||||
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
|
||||
}
|
||||
}
|
||||
|
||||
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
|
||||
const shells = locationResource("shell", async (location) => {
|
||||
const response = await api().shell.list({ location })
|
||||
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
|
||||
return {
|
||||
location: response.location,
|
||||
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
on: config.event.on,
|
||||
listen: config.event.listen,
|
||||
@@ -1321,12 +1293,17 @@ export function createData(config: CreateDataInput) {
|
||||
status(sessionID: string) {
|
||||
return store.session.active[sessionID] ?? "idle"
|
||||
},
|
||||
// Inputs are the pending user and synthetic items; compactions are control items.
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
|
||||
item.type === "compaction" ? [] : [item.id],
|
||||
)
|
||||
},
|
||||
has(sessionID: string, inboxID: string) {
|
||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||
return (
|
||||
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
|
||||
)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
@@ -1350,12 +1327,6 @@ export function createData(config: CreateDataInput) {
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
merged.forEach(materializeInboxMessage)
|
||||
})
|
||||
})
|
||||
@@ -1740,7 +1711,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
shell: {
|
||||
list(location?: LocationRef) {
|
||||
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||
return Object.values(shells.list(location) ?? {})
|
||||
},
|
||||
listBySession(sessionID: string) {
|
||||
return Object.values(store.location)
|
||||
@@ -1752,31 +1723,8 @@ export function createData(config: CreateDataInput) {
|
||||
.map((data) => data.shell?.[id])
|
||||
.find((shell) => shell !== undefined)
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.shell:${id}`, async () => {
|
||||
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
sync: shells.sync,
|
||||
invalidate: shells.invalidate,
|
||||
},
|
||||
location: {
|
||||
info(ref?: LocationRef) {
|
||||
@@ -1831,162 +1779,20 @@ export function createData(config: CreateDataInput) {
|
||||
result.shell.invalidate(location)
|
||||
result.session.form.invalidate("global", location)
|
||||
},
|
||||
vcs: {
|
||||
info(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.vcs
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const location = ref ?? defaultLocation()
|
||||
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
|
||||
const response = await api().vcs.get({ location: locationQuery(location) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], vcs: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.agent:${id}`, async () => {
|
||||
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], agent: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.command:${id}`, async () => {
|
||||
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], command: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.integration:${id}`, async () => {
|
||||
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], integration: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
mcp: {
|
||||
server: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.server:${id}`, async () => {
|
||||
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, server: response.data },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.resource:${id}`, async () => {
|
||||
const response = await api().mcp.resource.catalog({
|
||||
location: locationQuery(ref ?? defaultLocation()),
|
||||
})
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.model:${id}`, async () => {
|
||||
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], model: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.provider:${id}`, async () => {
|
||||
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], provider: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
reference: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.reference:${id}`, async () => {
|
||||
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], reference: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
resource: locationResource("mcpResource", async (location) => {
|
||||
const response = await api().mcp.resource.catalog({ location })
|
||||
return { location: response.location, data: response.data.resources }
|
||||
}),
|
||||
},
|
||||
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
|
||||
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
|
||||
reference: locationResource("reference", (location) => api().reference.list({ location })),
|
||||
websearch: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearch
|
||||
@@ -2001,22 +1807,7 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.skill:${id}`, async () => {
|
||||
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], skill: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20378,44 +20378,6 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.added": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["plugin.added"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23123,9 +23085,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/permission.v2.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.added"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.updated"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun run script/test.ts",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"id": "7f833e4e-de8a-4792-a726-e70daa8b5a4e",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1406,7 +1406,7 @@
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_suspended",
|
||||
"name": "execution_claimed_at",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
@@ -2041,17 +2041,17 @@
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "time_suspended",
|
||||
"value": "execution_claimed_at",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"session_v2\".\"time_suspended\" is not null",
|
||||
"where": "\"session_v2\".\"execution_claimed_at\" is not null",
|
||||
"origin": "manual",
|
||||
"name": "session_v2_time_suspended_idx",
|
||||
"name": "session_v2_execution_claimed_at_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_v2"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
"renames": ["session_v2.time_suspended->session_v2.execution_claimed_at"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Measures the heap retained by Location service graphs and by the models.dev
|
||||
// catalog plugin. Everything runs against a temporary global directory with a
|
||||
// temporary home, an in-memory database, no filesystem watchers, and no network,
|
||||
// so it never touches a live server, database, or user configuration.
|
||||
//
|
||||
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
|
||||
//
|
||||
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
|
||||
// highest heapUsed sampled without forcing GC and are reported separately.
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { heapStats } from "bun:jsc"
|
||||
import { Effect, Layer, Logger, Scope } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Catalog } from "../src/catalog"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Integration } from "../src/integration"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Plugin } from "../src/plugin"
|
||||
import { ModelsDevPlugin } from "../src/plugin/models-dev"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Watcher } from "../src/filesystem/watcher"
|
||||
import { location } from "../test/fixture/location"
|
||||
import { catalogHost, host, integrationHost } from "../test/plugin/host"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string, fallback: number) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
if (index === -1) return fallback
|
||||
const value = Number(args[index + 1])
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
console.error(`--${name} must be a positive integer`)
|
||||
process.exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const locationCount = flag("locations", 6)
|
||||
const pluginCount = flag("plugins", 8)
|
||||
const jsonIndex = args.indexOf("--json")
|
||||
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
|
||||
|
||||
type Sample = { heapUsed: number; rss: number; objects: number }
|
||||
|
||||
const sample = (): Sample => {
|
||||
Bun.gc(true)
|
||||
Bun.gc(true)
|
||||
const usage = process.memoryUsage()
|
||||
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
|
||||
}
|
||||
|
||||
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
|
||||
|
||||
const median = (values: ReadonlyArray<number>) => {
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
const upper = sorted[middle] ?? 0
|
||||
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
|
||||
}
|
||||
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
|
||||
const globalLayer = Global.layerWith({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
cache: path.join(root, "cache"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
bin: path.join(root, "cache", "bin"),
|
||||
log: path.join(root, "data", "log"),
|
||||
repos: path.join(root, "data", "repos"),
|
||||
})
|
||||
const replacements = [
|
||||
Global.node.replace(globalLayer),
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
]
|
||||
|
||||
// One full Location graph per directory, retained for the rest of the run, the
|
||||
// way a long-running server retains every directory a client has touched.
|
||||
const locationsProgram = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
const rss: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < locationCount; index++) {
|
||||
const directory = path.join(root, "projects", `location-${index}`)
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
const context = yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
|
||||
.pipe(Scope.provide(scope))
|
||||
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
|
||||
yield* plugins.awaitActivation
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
rss.push(current.rss)
|
||||
previous = current
|
||||
}
|
||||
const catalog = yield* Catalog.Service.pipe(
|
||||
Effect.provideContext(
|
||||
yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
|
||||
.pipe(Scope.provide(scope)),
|
||||
),
|
||||
)
|
||||
const models = yield* catalog.model.all()
|
||||
const providers = yield* catalog.provider.all()
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
rss,
|
||||
catalog: { providers: providers.length, models: models.length },
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
// The models.dev plugin alone, against a real Catalog and Integration state per
|
||||
// instance, isolates the catalog-copy contribution from the rest of the graph.
|
||||
const pluginProgram = Effect.gen(function* () {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const snapshot = yield* modelsDev.get()
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < pluginCount; index++) {
|
||||
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location(Location.Ref.make({ directory }))),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
Location.node.replace(locationLayer),
|
||||
...replacements,
|
||||
]),
|
||||
).pipe(Scope.provide(scope))
|
||||
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
|
||||
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
|
||||
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
|
||||
yield* catalog.model.all()
|
||||
yield* integration.list()
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
previous = current
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
snapshot: {
|
||||
providers: snapshot.length,
|
||||
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const plugin = yield* pluginProgram.pipe(
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
|
||||
)
|
||||
const locations = yield* locationsProgram.pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
|
||||
),
|
||||
)
|
||||
return { plugin, locations }
|
||||
}).pipe(Effect.provide(Logger.layer([])))
|
||||
|
||||
const result = await Effect.runPromise(program)
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
|
||||
|
||||
console.log(
|
||||
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
|
||||
)
|
||||
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
|
||||
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
|
||||
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
|
||||
console.log(
|
||||
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
|
||||
)
|
||||
console.log(
|
||||
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
|
||||
)
|
||||
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
|
||||
console.log(
|
||||
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
|
||||
)
|
||||
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
|
||||
|
||||
if (jsonPath) {
|
||||
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { Location } from "../../location.js"
|
||||
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugin entrypoints can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Configured local plugins can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those targets directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
@@ -56,7 +57,10 @@ export const layer = Layer.effect(
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: operation.target,
|
||||
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
|
||||
})
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -148,24 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}
|
||||
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
|
||||
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
|
||||
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
|
||||
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
return Option.some<Operation>(operation)
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.orElseSucceed(() => operation),
|
||||
)
|
||||
})
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
|
||||
Effect.gen(function* () {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
|
||||
if (!(yield* fs.existsSafe(operation.target))) return [operation]
|
||||
const directory = yield* fs.isDir(operation.target)
|
||||
const entrypoints: Host.Entrypoints = directory
|
||||
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
|
||||
: { server: pathToFileURL(operation.target).href }
|
||||
if (!entrypoints.server) return []
|
||||
if (directory) {
|
||||
const root = yield* fs.resolve(operation.target)
|
||||
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
|
||||
if (!FSUtil.contains(root, server)) return []
|
||||
}
|
||||
const times = yield* Effect.forEach(
|
||||
[
|
||||
...Object.values(entrypoints)
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => fileURLToPath(entry)),
|
||||
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
|
||||
],
|
||||
(entry) =>
|
||||
fs.stat(entry).pipe(
|
||||
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
|
||||
Effect.orElseSucceed(() => 0),
|
||||
),
|
||||
)
|
||||
return [{ ...operation, mtime: Math.max(...times) }]
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flat()))
|
||||
})
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260903010538_execution_claimed_at.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -93,4 +94,5 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260903010538_execution_claimed_at",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` RENAME COLUMN \`time_suspended\` TO \`execution_claimed_at\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_v2_time_suspended_idx\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -214,7 +214,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`execution_claimed_at\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -272,7 +272,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -772,7 +772,7 @@ function importNextDatabase(
|
||||
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
|
||||
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
time_archived, execution_claimed_at
|
||||
) VALUES (
|
||||
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
|
||||
+31
-22
@@ -20,7 +20,9 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
|
||||
// One slot per requested definition in activation order, including ones whose setup failed, so
|
||||
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
|
||||
const active = new Map<Plugin.ID, Slot>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
@@ -52,7 +54,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
@@ -91,7 +92,7 @@ const layer = Layer.effect(
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
if (prefix === definitions.length && active.size === definitions.length) {
|
||||
const nextInventory = [...definitions.map(activeInfo), ...failures]
|
||||
const nextInventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -104,33 +105,33 @@ const layer = Layer.effect(
|
||||
const previous = new Map(Array.from(active.entries()).slice(prefix))
|
||||
yield* Effect.forEach(
|
||||
Array.from(previous.entries()).toReversed(),
|
||||
([id, entry]) =>
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
yield* Scope.close(entry.scope, Exit.void)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const nextInventory = definitions.slice(0, prefix).map(activeInfo)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
})
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
})
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
|
||||
const fallback = previous.get(definition.id)
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: fallback.plugin, scope: restored.scope })
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -138,7 +139,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
inventory = [...nextInventory, ...failures]
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -167,12 +168,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Generation): Plugin.Info {
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, ...plugin.features },
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
// The normalized snapshot is shared by every Location and only read here; the catalog
|
||||
// receives copies below, so retaining a second copy per Location is unnecessary.
|
||||
const loaded = { data: snapshots(yield* modelsDev.get()) }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
for (const provider of loaded.data) {
|
||||
@@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const provider of loaded.data) {
|
||||
catalog.provider.update(provider.info.id, (draft) => {
|
||||
Object.assign(draft, provider.info)
|
||||
Object.assign(draft, copy(provider.info))
|
||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
@@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return copy(data).filter(
|
||||
return 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.
|
||||
// The catalog owns and mutates its provider and model records in place, so every rebuild
|
||||
// needs fresh copies of the thousands of shared snapshot records. 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") {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as PluginModule from "./module.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
@@ -35,21 +35,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed: Npm.EntryPoint = local
|
||||
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
|
||||
const npm = yield* Npm.Service
|
||||
const installed = local
|
||||
? undefined
|
||||
: options?.install === false
|
||||
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
? yield* npm.resolve(operation.target)
|
||||
: yield* npm.add(operation.target)
|
||||
// Legacy auto-discovery still admits standalone server sources. Configured
|
||||
// local plugins always arrive here as directories.
|
||||
const entrypoints: Host.Entrypoints =
|
||||
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
|
||||
? { server: pathToFileURL(operation.target).href }
|
||||
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
|
||||
const entrypoint = entrypoints.server
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
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 target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).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 mod = yield* Effect.promise(() => Host.load(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
@@ -60,45 +66,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
),
|
||||
)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const features = local
|
||||
? yield* localFeatures(operation.target)
|
||||
: yield* Effect.all({
|
||||
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
|
||||
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
|
||||
}).pipe(
|
||||
Effect.map((resolved) => ({
|
||||
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
|
||||
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
|
||||
})),
|
||||
)
|
||||
return {
|
||||
id: plugin.id,
|
||||
features,
|
||||
revision: JSON.stringify([operation, installed.revision]),
|
||||
features: {
|
||||
...(entrypoints.tui ? { tui: true as const } : {}),
|
||||
...(entrypoints.rpc ? { rpc: true as const } : {}),
|
||||
},
|
||||
revision: JSON.stringify([operation, installed?.revision]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
? { type: "local" as const, path: fileURLToPath(entrypoint) }
|
||||
: {
|
||||
type: "package" as const,
|
||||
target: operation.target,
|
||||
...(installed.version ? { version: installed.version } : {}),
|
||||
...(installed?.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Generation
|
||||
})
|
||||
|
||||
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 has = (name: string) =>
|
||||
["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 } : {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = (directory: string) => `<system-reminder>
|
||||
You are in Plan mode. You may optionally create or update plan documents in:
|
||||
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
|
||||
${directory}
|
||||
|
||||
Do not modify any other files or ask a subagent to do so.
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
|
||||
const installedPath = packageName.startsWith("file://")
|
||||
? packageName
|
||||
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
|
||||
: yield* npm.add(packageName).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
|
||||
)
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
|
||||
return mod[match]
|
||||
|
||||
@@ -22,31 +22,12 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
|
||||
Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
|
||||
if (entry.type === "directory") return Option.some(entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
|
||||
return Option.none<string>()
|
||||
}),
|
||||
)
|
||||
return targets.flatMap(Option.toArray)
|
||||
})
|
||||
|
||||
export function entrypoint(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
return yield* Effect.findFirst(
|
||||
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Layer, Stream } from "effect"
|
||||
import { Cause, Effect, Layer, Queue, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -19,6 +19,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
post: readonly Plugin.Generation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
install: boolean,
|
||||
running: ReadonlyMap<string, Plugin.Generation>,
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
@@ -71,6 +72,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
state: { status: "failed", error: plugin.error, ref: plugin.ref },
|
||||
features: { server: true },
|
||||
})
|
||||
// The new revision never became a generation, so the one already running keeps its place.
|
||||
const retained = packages.get(operation.target) ?? running.get(operation.target)
|
||||
if (!retained) continue
|
||||
packages.set(operation.target, retained)
|
||||
enabled.add(retained.id)
|
||||
continue
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
@@ -91,6 +97,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
ordered.findIndex((other) => other.id === plugin.id) !== index
|
||||
return {
|
||||
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
|
||||
packages: new Map([...packages].filter(([, plugin]) => enabled.has(plugin.id))),
|
||||
failures: [
|
||||
...failures.values(),
|
||||
...ordered.filter(duplicate).map((plugin) => ({
|
||||
@@ -118,6 +125,8 @@ export const layer = Layer.effectDiscard(
|
||||
// Built-ins capture services from this layer; unload them before those services close.
|
||||
yield* Effect.addFinalizer(registry.close)
|
||||
let packages = new Set<string>()
|
||||
// Last generation handed to the registry per target; a failed reload keeps it in place.
|
||||
let running = new Map<string, Plugin.Generation>()
|
||||
let outdated = new Set<string>()
|
||||
const updating = new Set<string>()
|
||||
let generation = 0
|
||||
@@ -140,7 +149,7 @@ export const layer = Layer.effectDiscard(
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false)
|
||||
const immediate = yield* resolve(pre, post, operations, false, running)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package"
|
||||
? {
|
||||
@@ -155,16 +164,17 @@ export const layer = Layer.effectDiscard(
|
||||
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
|
||||
)
|
||||
yield* apply(immediate)
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
|
||||
if (resolved !== immediate) yield* apply(resolved)
|
||||
const loaded = new Set(
|
||||
running = resolved.packages
|
||||
const targets = new Set(
|
||||
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
|
||||
plugin.source?.type === "package" ? [plugin.source.target] : [],
|
||||
),
|
||||
)
|
||||
packages = loaded
|
||||
packages = targets
|
||||
yield* Effect.forEach(
|
||||
loaded,
|
||||
targets,
|
||||
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
@@ -178,8 +188,24 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
const reloads = Stream.merge(
|
||||
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
|
||||
// Start source consumers before activation, without an extra merge/debounce boundary delaying them.
|
||||
// Each source owns its upstream subscriptions; the queue retains the latest observed request.
|
||||
const triggers = yield* Queue.sliding<number>(1)
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
const notify = Effect.gen(function* () {
|
||||
observed++
|
||||
if (!release) release = yield* registry.hold()
|
||||
yield* Queue.offer(triggers, observed)
|
||||
})
|
||||
const watch = <A>(stream: Stream.Stream<A>) =>
|
||||
stream.pipe(
|
||||
Stream.runForEach(() => notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* watch(sources.changes())
|
||||
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
|
||||
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
|
||||
yield* watch(
|
||||
updates.changes().pipe(
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
@@ -188,22 +214,10 @@ export const layer = Layer.effectDiscard(
|
||||
update.updating ? updating.add(update.target) : updating.delete(update.target)
|
||||
}),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (!release) release = yield* registry.hold()
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
// Run initial activation immediately; debounce only later requests. One consumer serializes both.
|
||||
yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(triggers).pipe(Stream.debounce("100 millis"))).pipe(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
@@ -215,17 +229,6 @@ export const layer = Layer.effectDiscard(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("24 hours").pipe(
|
||||
Effect.andThen(
|
||||
Effect.acquireUseRelease(
|
||||
registry.hold(),
|
||||
() => activate(),
|
||||
(release) => release,
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -95,8 +95,7 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
|
||||
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
|
||||
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
|
||||
const entrypoint = yield* Effect.try({
|
||||
try: () =>
|
||||
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
|
||||
try: () => resolveModule(specifier, installed.directory),
|
||||
catch: (cause) => new LoadError({ package: specifier, cause }),
|
||||
})
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
|
||||
+12
-19
@@ -117,29 +117,11 @@ const layer = Layer.effect(
|
||||
const parsed = yield* parse(method.input, input).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
|
||||
)
|
||||
// Handler defects and undeclared errors become the same typed rpc.internal failure the HTTP handler
|
||||
// exposes, so an in-process plugin caller can recover exactly like a remote one.
|
||||
const internal = (error: unknown, message = "RPC call failed") =>
|
||||
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, error }).pipe(
|
||||
Effect.andThen(Effect.fail(failure("rpc.internal", message))),
|
||||
)
|
||||
const result = yield* Effect.suspend(() => {
|
||||
// The heterogeneous registry erases handlers after their selected schema validates input.
|
||||
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
|
||||
return execution
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!(error instanceof DeclaredError)) return internal(error)
|
||||
const declared = method.errors && Object.hasOwn(method.errors, error.type) && method.errors[error.type]
|
||||
if (!declared) return internal(error, `Undeclared RPC error: ${error.type}`)
|
||||
return encode(declared, error.data).pipe(
|
||||
Effect.catch((cause) => Effect.die(cause)),
|
||||
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
|
||||
)
|
||||
}),
|
||||
// Also covers declared error data that fails its own schema: a handler bug, not a caller error.
|
||||
Effect.catchDefect((defect) => internal(defect)),
|
||||
)
|
||||
}).pipe(Effect.catch((error) => encodeError(method, error)))
|
||||
return yield* encode(method.output, result).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
|
||||
)
|
||||
@@ -233,6 +215,17 @@ function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown
|
||||
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
|
||||
}
|
||||
|
||||
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
|
||||
if (!(error instanceof DeclaredError)) return Effect.die(error)
|
||||
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
|
||||
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
|
||||
}
|
||||
return encode(method.errors[error.type], error.data).pipe(
|
||||
Effect.catch((cause) => Effect.die(cause)),
|
||||
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
|
||||
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
|
||||
return read(method.errors[error.type], error.data).pipe(
|
||||
|
||||
@@ -53,7 +53,9 @@ export interface Prepared {
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
readonly executeTool: (
|
||||
input: Parameters<Tool.Snapshot["execute"]>[0],
|
||||
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
import { Effect, Fiber, Iterable } from "effect"
|
||||
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -12,7 +12,7 @@ import { SessionSchema } from "../schema.js"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Tool } from "../../tool.js"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -557,20 +557,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
|
||||
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.NormalizedResult) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
|
||||
if (tool.name !== name)
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
|
||||
tool.settled = true
|
||||
const content =
|
||||
typeof result.content === "string"
|
||||
? [{ type: "text" as const, text: result.content }]
|
||||
: result.content === undefined
|
||||
? []
|
||||
: [...result.content]
|
||||
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
const content = result.content
|
||||
if (!isReadonlyArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
|
||||
@@ -62,17 +62,17 @@ export const SessionTable = sqliteTable(
|
||||
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
time_suspended: integer(),
|
||||
/** Write-ahead recovery marker; see SessionStore.claim. */
|
||||
execution_claimed_at: integer(),
|
||||
resume_attempts: integer().notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
index("session_v2_workspace_idx").on(table.workspace_id),
|
||||
index("session_v2_parent_idx").on(table.parent_id),
|
||||
index("session_v2_time_suspended_idx")
|
||||
.on(table.time_suspended)
|
||||
.where(sql`${table.time_suspended} is not null`),
|
||||
index("session_v2_execution_claimed_at_idx")
|
||||
.on(table.execution_claimed_at)
|
||||
.where(sql`${table.execution_claimed_at} is not null`),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ const layer = Layer.effect(
|
||||
return yield* db
|
||||
.select({ sessionID: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
|
||||
.where(and(isNotNull(SessionTable.execution_claimed_at), isNull(SessionTable.parent_id)))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
@@ -205,15 +205,15 @@ const layer = Layer.effect(
|
||||
// pinned so session ordering only moves on real changes.
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
|
||||
.set({ execution_claimed_at: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.execution_claimed_at)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
release: Effect.fn("SessionStore.release")(function* (sessionID) {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -221,10 +221,10 @@ const layer = Layer.effect(
|
||||
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(
|
||||
and(
|
||||
isNotNull(SessionTable.time_suspended),
|
||||
isNotNull(SessionTable.execution_claimed_at),
|
||||
isNotNull(SessionTable.parent_id),
|
||||
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ToolOutput from "./tool-output.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Tool } from "./tool.js"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -15,7 +15,7 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
type Result = Tool.NormalizedResult
|
||||
|
||||
type Limits = {
|
||||
maxLines: number
|
||||
@@ -64,8 +64,7 @@ const layer = Layer.effect(
|
||||
|
||||
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const content = result.content
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const limits = state.get()
|
||||
const lines = text.split("\n")
|
||||
|
||||
@@ -42,6 +42,11 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
/** A local execution result after hooks and content normalization. */
|
||||
export interface NormalizedResult extends Tool.Result {
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeCatalog?: CodeModeCatalog.Inventory
|
||||
@@ -53,7 +58,7 @@ export interface Snapshot {
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
|
||||
readonly definitions?: ReadonlyMap<string, ToolDefinition>
|
||||
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
|
||||
}) => Effect.Effect<NormalizedResult, Tool.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
|
||||
expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
@@ -38,7 +40,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
|
||||
if (result.metadata?.truncated === false) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import executionClaimMigration from "@opencode-ai/core/database/migration/20260903010538_execution_claimed_at"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
SqliteClient.layer({ filename: ":memory:", disableWAL: true }),
|
||||
Layer.succeed(Global.Service, Global.make()),
|
||||
),
|
||||
)
|
||||
|
||||
describe("execution claim column", () => {
|
||||
it.live("bootstraps the renamed column with an unclaimed default and its partial index", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES ('project', '/repo', 1, 2, '[]')
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 (id, project_id, slug, directory, version, time_created, time_updated)
|
||||
VALUES ('session', 'project', 'session', '/repo', '2', 1, 2)
|
||||
`)
|
||||
|
||||
expect(yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2`)).toEqual({
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${executionClaimMigration.id}`)).toEqual({
|
||||
id: executionClaimMigration.id,
|
||||
})
|
||||
expect(
|
||||
yield* db.all(sql`SELECT name FROM pragma_table_info('session_v2') WHERE name = 'time_suspended'`),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* db.get(sql`
|
||||
SELECT name, partial FROM pragma_index_list('session_v2')
|
||||
WHERE name = 'session_v2_execution_claimed_at_idx'
|
||||
`),
|
||||
).toEqual({ name: "session_v2_execution_claimed_at_idx", partial: 1 })
|
||||
expect(yield* db.all(sql`SELECT name FROM pragma_index_info('session_v2_execution_claimed_at_idx')`)).toEqual([
|
||||
{ name: "execution_claimed_at" },
|
||||
])
|
||||
yield* DatabaseMigration.apply(db)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates existing claims without resetting attempts, activity, or dependent history", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_v2 (
|
||||
id text PRIMARY KEY,
|
||||
parent_id text,
|
||||
title text,
|
||||
time_created integer NOT NULL,
|
||||
time_updated integer NOT NULL,
|
||||
time_suspended integer,
|
||||
resume_attempts integer DEFAULT 0 NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE INDEX session_v2_time_suspended_idx ON session_v2 (time_suspended)
|
||||
WHERE "session_v2"."time_suspended" IS NOT NULL
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session_v2(id) ON DELETE CASCADE,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 VALUES
|
||||
('claimed', NULL, 'Claimed', 1, 2, 1234, 2),
|
||||
('child', 'claimed', 'Child', 3, 4, 5678, 1),
|
||||
('idle', NULL, 'Idle', 5, 6, NULL, 0)
|
||||
`)
|
||||
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'claimed', '{"text":"preserved"}')`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* Effect.forEach(
|
||||
migrations.filter((migration) => migration.id !== executionClaimMigration.id),
|
||||
(migration) => db.run(sql`INSERT INTO migration VALUES (${migration.id}, 1)`),
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
expect(yield* db.all(sql`SELECT * FROM session_v2 ORDER BY id`)).toEqual([
|
||||
{
|
||||
id: "child",
|
||||
parent_id: "claimed",
|
||||
title: "Child",
|
||||
time_created: 3,
|
||||
time_updated: 4,
|
||||
execution_claimed_at: 5678,
|
||||
resume_attempts: 1,
|
||||
},
|
||||
{
|
||||
id: "claimed",
|
||||
parent_id: null,
|
||||
title: "Claimed",
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
execution_claimed_at: 1234,
|
||||
resume_attempts: 2,
|
||||
},
|
||||
{
|
||||
id: "idle",
|
||||
parent_id: null,
|
||||
title: "Idle",
|
||||
time_created: 5,
|
||||
time_updated: 6,
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
},
|
||||
])
|
||||
expect(yield* db.all(sql`SELECT * FROM session_message`)).toEqual([
|
||||
{ id: "message", session_id: "claimed", data: '{"text":"preserved"}' },
|
||||
])
|
||||
expect(yield* db.all(sql`PRAGMA foreign_key_check`)).toEqual([])
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(sql`
|
||||
SELECT name FROM pragma_index_list('session_v2') WHERE name = 'session_v2_time_suspended_idx'
|
||||
`),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* db.all(sql`
|
||||
SELECT id FROM session_v2 INDEXED BY session_v2_execution_claimed_at_idx
|
||||
WHERE execution_claimed_at IS NOT NULL ORDER BY id
|
||||
`),
|
||||
).toEqual([{ id: "child" }, { id: "claimed" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
|
||||
// Core is env-free, so the default ModelsDev node refreshes from models.dev
|
||||
// unless the graph says otherwise. Real-Location fixtures opt out here; the
|
||||
// test harness refuses any request that slips past.
|
||||
export const offlineModels = ModelsDev.node.replace(ModelsDev.configured({ fetch: false }))
|
||||
@@ -15,12 +15,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
@@ -47,6 +48,7 @@ const instances = Layer.effect(
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
@@ -61,6 +63,7 @@ const instances = Layer.effect(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, type Scope } from "effect"
|
||||
import { TestClock, TestConsole } from "effect/testing"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
const loopback = new Set(["127.0.0.1", "localhost", "[::1]"])
|
||||
|
||||
// Core is env-free, so nothing tells the default node graph to stay offline;
|
||||
// a test that boots it would phone home through FetchHttpClient. Every
|
||||
// FetchHttpClient reads this reference at request time, so refusing here covers
|
||||
// each node that shares the default client, while an explicit HttpClient
|
||||
// replacement never reaches it. Callers such as ModelsDev swallow request
|
||||
// failures, so the harness also records the attempt and fails the test itself.
|
||||
export const refuseNetwork = (violations: string[]): typeof fetch =>
|
||||
Object.assign(
|
||||
(input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
if (loopback.has(new URL(url).hostname)) return fetch(input, init)
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
const message = `test attempted network request: ${method} ${url} — provide an explicit HttpClient or disable the fetch`
|
||||
violations.push(message)
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
|
||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||
const violations: string[] = []
|
||||
const exit = yield* body(value).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
Effect.provideService(FetchHttpClient.Fetch, refuseNetwork(violations)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
||||
yield* Effect.logError(err)
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return yield* Effect.fail(new Error(violations.join("\n")))
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
@@ -34,6 +35,7 @@ import { Tool } from "../src/tool"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { it, refuseNetwork } from "./lib/effect"
|
||||
|
||||
describe("test harness network guard", () => {
|
||||
test("refuses requests to hosts other than loopback", async () => {
|
||||
const violations: string[] = []
|
||||
const refused = refuseNetwork(violations)
|
||||
await expect(refused("https://models.opencode.ai/api.json")).rejects.toThrow(
|
||||
"test attempted network request: GET https://models.opencode.ai/api.json — provide an explicit HttpClient or disable the fetch",
|
||||
)
|
||||
await expect(refused(new Request("https://example.invalid/", { method: "POST" }))).rejects.toThrow(
|
||||
"test attempted network request: POST https://example.invalid/",
|
||||
)
|
||||
expect(violations).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.live("the default http client node requests through the harness fetch", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: string[] = []
|
||||
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
|
||||
Effect.provideService(
|
||||
FetchHttpClient.Fetch,
|
||||
Object.assign(
|
||||
(input: string | URL | Request) => {
|
||||
seen.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
|
||||
return Promise.resolve(new Response("from fetch"))
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response).toBe("from fetch")
|
||||
expect(seen).toEqual(["https://example.invalid/catalog"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an explicit HttpClient replacement is what the node sees", () =>
|
||||
Effect.gen(function* () {
|
||||
const mock = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("from mock"))),
|
||||
)
|
||||
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNodePlatform.httpClient, [
|
||||
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, mock)),
|
||||
]),
|
||||
),
|
||||
)
|
||||
expect(response).toBe("from mock")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loopback requests pass through to the real fetch", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("local") })),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const response = yield* HttpClient.get(`http://127.0.0.1:${server.port}/`).pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
|
||||
)
|
||||
expect(response).toBe("local")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -162,7 +162,7 @@ describe("Npm.cacheKey", () => {
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
test("locates cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "@fixture/provider@1.0.0"
|
||||
const directory = path.join(
|
||||
@@ -185,24 +185,7 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "fixture provider"
|
||||
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, { name: spec, exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
|
||||
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toBe(directory)
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.name).toBe("@fixture/provider")
|
||||
})
|
||||
|
||||
test("reifies when package cache directory exists without the package installed", async () => {
|
||||
@@ -224,16 +207,16 @@ describe("Npm.add", () => {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
|
||||
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
})
|
||||
|
||||
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
|
||||
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec =
|
||||
@@ -250,7 +233,7 @@ describe("Npm.add", () => {
|
||||
}
|
||||
}).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.directory).toEndWith(path.join("node_modules", entries.added.name))
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
@@ -271,7 +254,7 @@ describe("Npm.add", () => {
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(entry.name).toBe("fixture-subdirectory-plugin")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
@@ -319,19 +302,44 @@ describe("Npm.add", () => {
|
||||
expect(result.pinned).toContain("root: true")
|
||||
expect(result.current).toBeFalse()
|
||||
}, 30_000)
|
||||
|
||||
// Symlink creation needs elevated privileges on Windows.
|
||||
test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
await fs.mkdir(path.join(tmp.path, "cache"))
|
||||
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
|
||||
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const added = yield* npm.add(mutable)
|
||||
const current = yield* npm.check(mutable)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
return { added, current, outdated: yield* npm.check(mutable) }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
|
||||
|
||||
expect(result.added.version).toBe(fixture.commit)
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
test("resolves a TUI entrypoint only when the package is already cached", async () => {
|
||||
test("locates a cached package without installing it", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(missing.entrypoint).toBeUndefined()
|
||||
expect(missing.version).toBeUndefined()
|
||||
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, {
|
||||
@@ -343,9 +351,9 @@ describe("Npm.resolve", () => {
|
||||
|
||||
const resolved = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
expect(resolved.directory).toBe(directory)
|
||||
expect(resolved.version).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,6 +154,48 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const setups = { good1: 0, broken: 0, good2: 0 }
|
||||
const good = (id: "good1" | "good2"): Plugin.Generation => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: () => Effect.sync(() => void setups[id]++),
|
||||
})
|
||||
const broken = (revision: string): Plugin.Generation => ({
|
||||
id: "broken",
|
||||
revision,
|
||||
effect: () =>
|
||||
Effect.suspend(() => {
|
||||
setups.broken++
|
||||
return Effect.die(new Error("Setup failed"))
|
||||
}),
|
||||
})
|
||||
const failed = () =>
|
||||
plugins.list().pipe(Effect.map((inventory) => inventory.find((plugin) => plugin.id === "broken")?.state))
|
||||
|
||||
yield* plugins.activate([good("good1"), broken("1"), good("good2")])
|
||||
expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 })
|
||||
expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") })
|
||||
|
||||
// Identical definitions: nothing restarts and the failed revision is not retried.
|
||||
yield* plugins.activate([good("good1"), broken("1"), good("good2")])
|
||||
expect(setups).toEqual({ good1: 1, broken: 1, good2: 1 })
|
||||
expect(yield* failed()).toMatchObject({ status: "failed", error: expect.stringContaining("Setup failed") })
|
||||
expect((yield* plugins.list()).map((plugin) => `${plugin.id}:${plugin.state.status}`)).toEqual([
|
||||
"good1:active",
|
||||
"broken:failed",
|
||||
"good2:active",
|
||||
])
|
||||
|
||||
// A new revision of the failed plugin is retried once, restarting only the suffix behind it.
|
||||
yield* plugins.activate([good("good1"), broken("2"), good("good2")])
|
||||
expect(setups).toEqual({ good1: 1, broken: 2, good2: 2 })
|
||||
expect(yield* failed()).toMatchObject({ status: "failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -39,10 +39,10 @@ import { emptyMcpLayer } from "../fixture/mcp"
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
add: (name) => Effect.succeed({ directory: "", name }),
|
||||
resolve: (name) => Effect.succeed({ directory: "", name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: (name) => Effect.succeed({ directory: "", name }),
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -31,10 +32,245 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const isolated = testEffect(Layer.empty)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
// One complete Location graph behind the production plugin host. Two of these stand in for
|
||||
// two Locations that share a single models.dev snapshot instance.
|
||||
const owner = Effect.gen(function* () {
|
||||
const context = yield* Layer.build(PluginTestLayer)
|
||||
const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context))
|
||||
return {
|
||||
context,
|
||||
host,
|
||||
bus: Context.get(context, Bus.Service),
|
||||
catalog: Context.get(context, Catalog.Service),
|
||||
integration: Context.get(context, Integration.Service),
|
||||
}
|
||||
})
|
||||
|
||||
// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any
|
||||
// existing nested value is observable on the source object.
|
||||
const richSnapshot = (name = "Acme") => {
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("gpt-5.4")
|
||||
const snapshot = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } },
|
||||
headers: { "x-acme": "provider" },
|
||||
body: { service_tier: "default", tags: ["stable"] },
|
||||
},
|
||||
environment: ["ACME_API_KEY", "ACME_HOST"],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
family: Model.Family.make("gpt"),
|
||||
settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } },
|
||||
headers: { "x-mode": "fast" },
|
||||
body: { service_tier: "priority", options: { top_k: 1 }, stop: ["<end>"] },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [
|
||||
{
|
||||
id: Model.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
body: { max_tokens: 1024 },
|
||||
},
|
||||
],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(2.5),
|
||||
output: Money.USDPerMillionTokens.make(15),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
{
|
||||
tier: { type: "context", size: 200_000 },
|
||||
input: Money.USDPerMillionTokens.make(5),
|
||||
output: Money.USDPerMillionTokens.make(22.5),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero },
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
return { providerID, modelID, snapshot }
|
||||
}
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () =>
|
||||
Effect.gen(function* () {
|
||||
const { providerID, modelID, snapshot } = richSnapshot()
|
||||
const pristine = JSON.stringify(snapshot)
|
||||
const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void })
|
||||
const first = yield* owner
|
||||
const second = yield* owner
|
||||
for (const each of [first, second])
|
||||
yield* ModelsDevPlugin.effect(each.host).pipe(
|
||||
Effect.provideService(ModelsDev.Service, source),
|
||||
Effect.provideContext(each.context),
|
||||
)
|
||||
|
||||
// The stored environment method must own its names array; the source array is shared.
|
||||
let names: readonly string[] | undefined
|
||||
yield* first.integration.transform((draft) => {
|
||||
names = draft.method
|
||||
.list(Integration.ID.make(providerID))
|
||||
.flatMap((method) => (method.type === "env" ? [method.names] : []))[0]
|
||||
})
|
||||
expect(names).toEqual(snapshot[0].environment)
|
||||
expect(names).not.toBe(snapshot[0].environment)
|
||||
|
||||
// Catalog-owned provider records are copies, not the source's nested objects.
|
||||
const stored = required(yield* first.catalog.provider.get(providerID))
|
||||
expect(stored.settings).toEqual(snapshot[0].info.settings)
|
||||
expect(stored.settings).not.toBe(snapshot[0].info.settings)
|
||||
expect(stored.headers).not.toBe(snapshot[0].info.headers)
|
||||
expect(stored.body).not.toBe(snapshot[0].info.body)
|
||||
|
||||
// A later plugin in the first Location mutates existing nested values in place through the
|
||||
// production host, the way the Bedrock, Azure, and config provider plugins do.
|
||||
const scope = yield* Scope.make()
|
||||
yield* first.host.catalog
|
||||
.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
required(provider.settings).baseURL = "https://override.acme.test/v1"
|
||||
required(provider.settings).thinking.type = "disabled"
|
||||
required(provider.headers)["x-acme"] = "override"
|
||||
required(provider.body).service_tier = "priority"
|
||||
required(provider.body).tags.push("override")
|
||||
})
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
required(model.settings).reasoning.effort = "high"
|
||||
required(model.headers)["x-mode"] = "slow"
|
||||
required(model.body).options.top_k = 7
|
||||
required(model.body).stop.push("<stop>")
|
||||
const variant = required(model.variants[0])
|
||||
required(variant.settings).thinking.type = "disabled"
|
||||
required(variant.body).max_tokens = 4096
|
||||
model.capabilities.input.push("image")
|
||||
required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9)
|
||||
required(required(model.cost[1]).tier).size = 1
|
||||
required(model.cost[1]).input = Money.USDPerMillionTokens.make(42)
|
||||
model.limit.context = 1
|
||||
model.time.released = 5
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
const mutatedProvider = required(yield* first.catalog.provider.get(providerID))
|
||||
const mutated = required(yield* first.catalog.model.get(providerID, modelID))
|
||||
expect(mutatedProvider.settings).toEqual({
|
||||
baseURL: "https://override.acme.test/v1",
|
||||
thinking: { type: "disabled", display: "summarized" },
|
||||
})
|
||||
expect(mutatedProvider.headers).toEqual({ "x-acme": "override" })
|
||||
expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] })
|
||||
expect(mutated.settings).toEqual({
|
||||
baseURL: "https://models.acme.test/v1",
|
||||
thinking: { type: "disabled", display: "summarized" },
|
||||
reasoning: { effort: "high" },
|
||||
})
|
||||
expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" })
|
||||
expect(mutated.body).toEqual({
|
||||
service_tier: "priority",
|
||||
tags: ["stable", "override"],
|
||||
options: { top_k: 7 },
|
||||
stop: ["<end>", "<stop>"],
|
||||
})
|
||||
expect(mutated.variants).toEqual([
|
||||
{
|
||||
id: Model.VariantID.make("low"),
|
||||
settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" },
|
||||
body: { max_tokens: 4096 },
|
||||
},
|
||||
])
|
||||
expect(mutated.capabilities.input).toEqual(["text", "image"])
|
||||
expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9))
|
||||
expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 })
|
||||
expect(mutated.limit.context).toBe(1)
|
||||
expect(mutated.time.released).toBe(5)
|
||||
|
||||
// The sibling Location and the shared source are untouched.
|
||||
const sibling = required(yield* second.catalog.model.get(providerID, modelID))
|
||||
expect(sibling.settings).toEqual({
|
||||
baseURL: "https://models.acme.test/v1",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
reasoning: { effort: "low" },
|
||||
})
|
||||
expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" })
|
||||
expect(sibling.body).toEqual({
|
||||
service_tier: "priority",
|
||||
tags: ["stable"],
|
||||
options: { top_k: 1 },
|
||||
stop: ["<end>"],
|
||||
})
|
||||
expect(sibling.variants).toEqual(snapshot[0].models[0].variants)
|
||||
expect(sibling.capabilities.input).toEqual(["text"])
|
||||
expect(sibling.cost).toEqual(snapshot[0].models[0].cost)
|
||||
expect(sibling.limit).toEqual(snapshot[0].models[0].limit)
|
||||
expect(sibling.time).toEqual(snapshot[0].models[0].time)
|
||||
expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
|
||||
// Removing the mutating transform rebuilds the first catalog from the shared source.
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling)
|
||||
expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
}),
|
||||
)
|
||||
|
||||
isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () =>
|
||||
Effect.gen(function* () {
|
||||
const initial = richSnapshot("Acme")
|
||||
const refreshed = richSnapshot("Acme Refreshed")
|
||||
const pristine = JSON.stringify(initial.snapshot)
|
||||
const current = { snapshot: initial.snapshot }
|
||||
const source = ModelsDev.Service.of({
|
||||
get: () => Effect.sync(() => current.snapshot),
|
||||
refresh: () => Effect.void,
|
||||
})
|
||||
const first = yield* owner
|
||||
const second = yield* owner
|
||||
for (const each of [first, second])
|
||||
yield* ModelsDevPlugin.effect(each.host).pipe(
|
||||
Effect.provideService(ModelsDev.Service, source),
|
||||
Effect.provideContext(each.context),
|
||||
)
|
||||
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme")
|
||||
|
||||
current.snapshot = refreshed.snapshot
|
||||
yield* first.bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
// Integration and catalog reloads are debounced sequentially.
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed")
|
||||
expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme")
|
||||
expect(JSON.stringify(initial.snapshot)).toBe(pristine)
|
||||
expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot))
|
||||
}),
|
||||
)
|
||||
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -92,6 +328,79 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () =>
|
||||
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")
|
||||
const snapshot = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
headers: { "x-acme": "provider" },
|
||||
},
|
||||
environment: ["ACME_API_KEY"],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "GPT-5.4",
|
||||
settings: { baseURL: "https://models.acme.test/v1" },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 1_050_000, output: 128_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
const pristine = JSON.stringify(snapshot)
|
||||
// The plugin receives the same snapshot instance every Location shares.
|
||||
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 }),
|
||||
),
|
||||
)
|
||||
|
||||
// Later plugins mutate nested provider and model records in place, as the Bedrock provider does.
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1"
|
||||
if (provider.headers) provider.headers["x-acme"] = "override"
|
||||
})
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1"
|
||||
model.variants.push({ id: Model.VariantID.make("configured") })
|
||||
model.capabilities.input.push("image")
|
||||
})
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1")
|
||||
expect(provider?.headers).toEqual({ "x-acme": "override" })
|
||||
expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1")
|
||||
expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }])
|
||||
expect(model?.capabilities.input).toEqual(["text", "image"])
|
||||
expect(JSON.stringify(snapshot)).toBe(pristine)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("plan plugin reminders", () => {
|
||||
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
|
||||
yield* settle(persisted, 2)
|
||||
expect(persisted[0]).toContain("You are in Plan mode")
|
||||
expect(persisted[0]).toContain("optionally create or update plan documents")
|
||||
expect(persisted[0]).toContain("Do not create or update plan files unless the user explicitly asks you to")
|
||||
expect(persisted[0]).toContain(planDirectory)
|
||||
expect(persisted[0]).toContain("Do not modify any other files")
|
||||
expect(persisted[1]).toContain("NO LONGER in Plan mode")
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Cause, Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -16,16 +15,15 @@ import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
|
||||
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
function npmPackage(directory = "") {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
add: (name) => Effect.succeed({ directory, name }),
|
||||
resolve: (name) => Effect.succeed({ directory, name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
update: (name) => Effect.succeed({ directory, name }),
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
}
|
||||
@@ -36,13 +34,16 @@ const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
|
||||
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
|
||||
})
|
||||
|
||||
function tempEntrypoint(source: string) {
|
||||
function tempPackage(source: string) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
|
||||
const entrypoint = path.join(directory, "provider.mjs")
|
||||
await Bun.write(entrypoint, source)
|
||||
return { directory, entrypoint }
|
||||
await Bun.write(path.join(directory, "provider.mjs"), source)
|
||||
await Bun.write(
|
||||
path.join(directory, "package.json"),
|
||||
JSON.stringify({ name: "fixture-provider", exports: "./provider.mjs" }),
|
||||
)
|
||||
return { directory }
|
||||
}),
|
||||
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
|
||||
)
|
||||
@@ -86,27 +87,11 @@ describe("DynamicProviderPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("injects the provider ID as the SDK factory name", () =>
|
||||
it.live("loads npm packages through their resolved import entrypoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
|
||||
modelID: Model.ID.make("test-model"),
|
||||
package: Provider.aisdk(fixtureProvider),
|
||||
}),
|
||||
package: fixtureProvider,
|
||||
options: { name: "custom-provider", marker: "dynamic" },
|
||||
})
|
||||
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads npm packages through their resolved import entrypoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
|
||||
const tmp = yield* tempPackage(`export { createFixtureProvider } from ${JSON.stringify(fixtureProvider)}\n`)
|
||||
yield* addPlugin(npmPackage(tmp.directory))
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
|
||||
@@ -123,7 +108,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin(npmEntrypoint())
|
||||
yield* addPlugin(npmPackage())
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
Model.Info.make({
|
||||
@@ -159,8 +144,8 @@ describe("DynamicProviderPlugin", () => {
|
||||
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||
const tmp = yield* tempPackage("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmPackage(tmp.directory))
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
Model.Info.make({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -608,7 +609,16 @@ describe("OpencodePlugin", () => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
// An env credential has no server metadata, so the plugin would ask the
|
||||
// default Console for remote config; answer 404 (no remote config) locally.
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
|
||||
@@ -14,10 +14,10 @@ import { PluginTestLayer } from "./fixture"
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
add: (name) => Effect.succeed({ directory: "", name }),
|
||||
resolve: (name) => Effect.succeed({ directory: "", name }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: (name) => Effect.succeed({ directory: "", name }),
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { Effect, Exit, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const Echo = Rpc.define({
|
||||
id: "shared-echo",
|
||||
methods: {
|
||||
echo: { input: Schema.String, output: Schema.String },
|
||||
fail: {
|
||||
input: Schema.String,
|
||||
output: Schema.String,
|
||||
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
|
||||
},
|
||||
},
|
||||
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
|
||||
})
|
||||
|
||||
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const events: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== "rpc.shared-echo.updated") return
|
||||
expect(event.location).toEqual({ directory: location.directory })
|
||||
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
|
||||
events.push(event.data.text)
|
||||
}),
|
||||
)
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* ctx.rpc.register(Echo, {
|
||||
echo: (value) => Effect.succeed(`${value}!`),
|
||||
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
|
||||
})
|
||||
yield* registration.events.emit("updated", { text: "ready" })
|
||||
}).pipe(Effect.orDie),
|
||||
},
|
||||
{
|
||||
id: "consumer",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
|
||||
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
|
||||
type: "missing",
|
||||
message: "Missing",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
},
|
||||
])
|
||||
expect(events).toEqual(["ready"])
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
|
||||
yield* plugins.activate([])
|
||||
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Echo, {
|
||||
echo: () => Effect.succeed("original"),
|
||||
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
},
|
||||
])
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "implementer",
|
||||
revision: "2",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Echo, {
|
||||
echo: () => Effect.succeed("replacement"),
|
||||
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
|
||||
})
|
||||
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
|
||||
},
|
||||
])
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
|
||||
}),
|
||||
)
|
||||
@@ -1,290 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
|
||||
import { Rpc } from "@opencode-ai/plugin/rpc"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("Promise plugin RPC", () => {
|
||||
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-calls",
|
||||
methods: {
|
||||
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
|
||||
ping: { input: z.undefined(), output: z.null() },
|
||||
errorShapedOutput: {
|
||||
input: z.undefined(),
|
||||
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
|
||||
},
|
||||
returned: {
|
||||
input: z.undefined(),
|
||||
output: z.null(),
|
||||
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
|
||||
},
|
||||
thrown: {
|
||||
input: z.undefined(),
|
||||
output: z.null(),
|
||||
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
|
||||
},
|
||||
defect: { input: z.undefined(), output: z.null() },
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-calls-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {
|
||||
standard: async (input) => {
|
||||
expect(input).toBe(42)
|
||||
return input + 1
|
||||
},
|
||||
ping: async () => null,
|
||||
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
|
||||
returned: async (_input, context) => context.error("rejected", "returned failure", { attempts: "1" }),
|
||||
thrown: async (_input, context) => {
|
||||
throw context.error("rejected", "thrown failure", { attempts: "2" })
|
||||
},
|
||||
defect: async () => {
|
||||
throw new Error("handler defect")
|
||||
},
|
||||
})
|
||||
const client = ctx.rpc(service)
|
||||
expect(await client.standard("42")).toBe("43")
|
||||
expect(await client.ping()).toBeNull()
|
||||
expect(await client.errorShapedOutput()).toEqual({
|
||||
type: "ordinary",
|
||||
message: "Success",
|
||||
data: { value: 1 },
|
||||
})
|
||||
await expect(client.returned()).rejects.toEqual({
|
||||
type: "rejected",
|
||||
message: "returned failure",
|
||||
data: { attempts: 1 },
|
||||
})
|
||||
await expect(client.thrown()).rejects.toEqual({
|
||||
type: "rejected",
|
||||
message: "thrown failure",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
await expect(client.defect()).rejects.toEqual({ type: "rpc.internal", message: "RPC call failed" })
|
||||
await registration.dispose()
|
||||
await registration.dispose()
|
||||
await expect(client.ping()).rejects.toBeDefined()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, revision: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-cancel",
|
||||
methods: { wait: { input: z.string(), output: z.string() } },
|
||||
events: {},
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-cancel-plugin",
|
||||
setup: async (ctx) => {
|
||||
const started = Promise.withResolvers<void>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
const signals = new Map<string, AbortSignal>()
|
||||
await ctx.rpc.register(service, {
|
||||
wait: async (input, call) => {
|
||||
signals.set(input, call.signal)
|
||||
if (input === "complete") return input
|
||||
started.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
call.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cancelled.resolve()
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
return input
|
||||
},
|
||||
})
|
||||
const client = ctx.rpc(service)
|
||||
const controller = new AbortController()
|
||||
const pending = client.wait("cancel", { signal: controller.signal })
|
||||
const rejected = pending.then(
|
||||
() => false,
|
||||
() => true,
|
||||
)
|
||||
await started.promise
|
||||
expect(await client.wait("complete")).toBe("complete")
|
||||
controller.abort()
|
||||
expect(await rejected).toBe(true)
|
||||
await cancelled.promise
|
||||
expect(signals.get("cancel")?.aborted).toBe(true)
|
||||
expect(signals.get("complete")?.aborted).toBe(false)
|
||||
expect(await client.wait("complete")).toBe("complete")
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, revision: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-async-listeners",
|
||||
methods: {},
|
||||
events: { updated: { schema: z.object({ value: z.number() }) } },
|
||||
})
|
||||
const error = new Error("Expected async plugin callback failure")
|
||||
const reported = Promise.withResolvers<void>()
|
||||
const logger = Logger.make((entry) => {
|
||||
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
|
||||
})
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-async-listeners-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {})
|
||||
const client = ctx.rpc(service)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const third = Promise.withResolvers<void>()
|
||||
const failed: number[] = []
|
||||
const healthy: number[] = []
|
||||
client.events.on("updated", async (event) => {
|
||||
failed.push(event.data.value)
|
||||
started.resolve()
|
||||
await release.promise
|
||||
throw error
|
||||
})
|
||||
client.events.on("updated", (event) => {
|
||||
healthy.push(event.data.value)
|
||||
if (event.data.value === 2) second.resolve()
|
||||
if (event.data.value === 3) third.resolve()
|
||||
})
|
||||
await registration.events.emit("updated", { value: 1 })
|
||||
await started.promise
|
||||
await registration.events.emit("updated", { value: 2 })
|
||||
await second.promise
|
||||
expect(failed).toEqual([1])
|
||||
release.resolve()
|
||||
await reported.promise
|
||||
await registration.events.emit("updated", { value: 3 })
|
||||
await third.promise
|
||||
expect(failed).toEqual([1])
|
||||
expect(healthy).toEqual([1, 2, 3])
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugins
|
||||
.activate([{ ...adapted, revision: "1" }])
|
||||
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
yield* plugins.activate([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const service = Rpc.define({
|
||||
id: "promise-rpc-events",
|
||||
methods: {},
|
||||
events: {
|
||||
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
|
||||
},
|
||||
})
|
||||
const subscriptions = Promise.withResolvers<{
|
||||
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
|
||||
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
|
||||
nativeIdle: AsyncIterator<unknown>
|
||||
}>()
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-rpc-events-plugin",
|
||||
setup: async (ctx) => {
|
||||
const registration = await ctx.rpc.register(service, {})
|
||||
const client = ctx.rpc(service)
|
||||
const first: string[] = []
|
||||
const second: string[] = []
|
||||
const firstSeen = Promise.withResolvers<void>()
|
||||
const secondSeen = Promise.withResolvers<void>()
|
||||
const nextSeen = Promise.withResolvers<void>()
|
||||
const unsubscribe = client.events.on("counted", (event) => {
|
||||
first.push(event.data.text)
|
||||
firstSeen.resolve()
|
||||
})
|
||||
client.events.on("counted", (event) => {
|
||||
second.push(event.data.text)
|
||||
if (event.data.text === "1") secondSeen.resolve()
|
||||
if (event.data.text === "2") nextSeen.resolve()
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
|
||||
const next = iterator.next()
|
||||
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
|
||||
const idleNext = idle.next()
|
||||
const nativeController = new AbortController()
|
||||
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
|
||||
const nativeNext = native.next()
|
||||
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
|
||||
const nativeIdleNext = nativeIdle.next()
|
||||
await registration.events.emit("counted", { count: 1 })
|
||||
await Promise.all([firstSeen.promise, secondSeen.promise])
|
||||
const event = (await next).value
|
||||
expect(event.type).toBe("rpc.promise-rpc-events.counted")
|
||||
expect(event.data).toEqual({ text: "1" })
|
||||
expect(typeof event.location.directory).toBe("string")
|
||||
expect((await idleNext).value.data).toEqual({ text: "1" })
|
||||
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
|
||||
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
|
||||
nativeController.abort()
|
||||
expect((await native.next()).done).toBe(true)
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
controller.abort()
|
||||
expect((await iterator.next()).done).toBe(true)
|
||||
await registration.events.emit("counted", { count: 2 })
|
||||
await nextSeen.promise
|
||||
expect(first).toEqual(["1"])
|
||||
expect(second).toEqual(["1", "2"])
|
||||
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
|
||||
expect((await aborted.next()).done).toBe(true)
|
||||
subscriptions.resolve({
|
||||
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
|
||||
idle,
|
||||
nativeIdle,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, revision: "1" }])
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
|
||||
const active = yield* Effect.promise(() => subscriptions.promise)
|
||||
yield* plugins.activate([])
|
||||
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
|
||||
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
|
||||
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, setDefaultTimeout } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, LayerMap, Schedule } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
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 { Npm } from "@opencode-ai/util/npm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
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 { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { advance } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Real Location boot with plugin-directory discovery, so local plugin files are loaded and reloaded.
|
||||
setDefaultTimeout(15_000)
|
||||
|
||||
// Package resolution can be held open so overlapping activations become observable.
|
||||
const npm = {
|
||||
directory: "",
|
||||
gate: undefined as Deferred.Deferred<void> | undefined,
|
||||
inflight: 0,
|
||||
peak: 0,
|
||||
}
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: (name) => Effect.succeed({ directory: npm.directory, name }),
|
||||
resolve: (name) =>
|
||||
Effect.gen(function* () {
|
||||
npm.inflight++
|
||||
npm.peak = Math.max(npm.peak, npm.inflight)
|
||||
if (npm.gate) yield* Deferred.await(npm.gate)
|
||||
npm.inflight--
|
||||
return { directory: npm.directory, name }
|
||||
}),
|
||||
check: () => Effect.succeed(false),
|
||||
update: (name) => Effect.succeed({ directory: npm.directory, name }),
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
|
||||
idleTimeToLive: Duration.infinity,
|
||||
})
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Npm.node.replace(npmLayer),
|
||||
Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)),
|
||||
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
|
||||
}),
|
||||
).pipe(Layer.provide(Watcher.testLayer))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]).pipe(Layer.provideMerge(Watcher.testLayer)),
|
||||
)
|
||||
|
||||
const greeter = (command: string) => `export default {
|
||||
id: "greeter",
|
||||
async setup(ctx) {
|
||||
await ctx.command.transform((editor) => editor.add({ name: "${command}", execute: async () => {} }))
|
||||
},
|
||||
}`
|
||||
|
||||
// Real-time polling: reloads do filesystem work that the TestClock cannot advance.
|
||||
const settle = (predicate: () => boolean, attempts = 200): Effect.Effect<void, string> =>
|
||||
Effect.suspend(() => {
|
||||
if (predicate()) return Effect.void
|
||||
if (attempts === 0) return Effect.fail("not settled")
|
||||
return Effect.promise(() => Bun.sleep(10)).pipe(Effect.andThen(settle(predicate, attempts - 1)))
|
||||
})
|
||||
|
||||
const failed = (plugins: Plugin.Interface) =>
|
||||
plugins.list().pipe(
|
||||
Effect.flatMap((inventory) => {
|
||||
const failure = inventory.find((plugin) => plugin.state.status === "failed" && plugin.source.type === "local")
|
||||
return failure ? Effect.succeed(failure) : Effect.fail("activation pending")
|
||||
}),
|
||||
Effect.retry({ times: 200, schedule: Schedule.spaced("25 millis") }),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
;(["discovered", "configured"] as const).forEach((mode) => {
|
||||
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const file = path.join(
|
||||
directory.path,
|
||||
mode === "discovered" ? ".opencode/plugins/greeter.ts" : "external/greeter/index.ts",
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, greeter("greet-v1"))
|
||||
await fs.utimes(file, new Date(0), new Date(0))
|
||||
if (mode === "configured") {
|
||||
await Bun.write(
|
||||
path.join(directory.path, ".opencode/opencode.json"),
|
||||
JSON.stringify({ plugins: [path.dirname(file)] }),
|
||||
)
|
||||
}
|
||||
})
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "gated",
|
||||
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
}),
|
||||
)
|
||||
const watcher = yield* Watcher.Test
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* Deferred.await(entered)
|
||||
// The real ConfigPluginSource merges config-root changes and configured-path watches.
|
||||
// Emit while setup is blocked, without a bus event that could mask a lost source trigger.
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, greeter("greet-v2"))
|
||||
await fs.utimes(file, new Date(), new Date())
|
||||
})
|
||||
yield* watcher.emit({ path: file, type: "update" })
|
||||
const ready = yield* plugins.awaitActivation.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* advance(() => ready.pollUnsafe() !== undefined)
|
||||
yield* Fiber.join(ready)
|
||||
|
||||
expect(yield* commands.get("greet-v1")).toBeUndefined()
|
||||
expect(yield* commands.get("greet-v2")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("keeps the running generation when an updated local plugin fails to import", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const file = path.join(directory.path, ".opencode/plugins/greeter.ts")
|
||||
// Local plugin revisions key on mtime, so give each rewrite a distinct timestamp.
|
||||
const write = (content: string, mtime: Date) =>
|
||||
Effect.promise(async () => {
|
||||
await Bun.write(file, content)
|
||||
await fs.utimes(file, mtime, mtime)
|
||||
})
|
||||
yield* write(greeter("greet-v1"), new Date(Date.now() - 60_000))
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("greet-v1")).toBeDefined()
|
||||
|
||||
yield* write("export default {", new Date())
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
const failure = yield* failed(plugins)
|
||||
|
||||
expect(failure).toMatchObject({ source: { type: "local", path: file }, state: { status: "failed" } })
|
||||
// The broken revision never produced a generation, so the previous one keeps running.
|
||||
expect(yield* commands.get("greet-v1")).toBeDefined()
|
||||
expect(yield* plugins.list()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "greeter",
|
||||
source: { type: "local", path: file },
|
||||
state: { status: "active" },
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes the periodic refresh behind an in-flight reload", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
npm.directory = path.join(directory.path, "fixture-pkg")
|
||||
npm.gate = undefined
|
||||
npm.peak = 0
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(npm.directory, "package.json"),
|
||||
JSON.stringify({ name: "fixture-pkg", exports: { "./server": "./server.ts" } }),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.write(path.join(npm.directory, "server.ts"), greeter("greet-pkg")))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: ["fixture-pkg"] })),
|
||||
)
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* TestClock.adjust("100 millis")
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("greet-pkg")).toBeDefined()
|
||||
expect(npm.peak).toBe(1)
|
||||
|
||||
// Hold the next resolve open, then let the 24 hour refresh fire while it is blocked.
|
||||
const gate = yield* Deferred.make<void>()
|
||||
npm.gate = gate
|
||||
npm.inflight = 0
|
||||
npm.peak = 0
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* TestClock.adjust("100 millis")
|
||||
yield* settle(() => npm.inflight === 1)
|
||||
yield* TestClock.adjust("24 hours")
|
||||
// Without serialization the refresh resolves concurrently with the held reload and the peak reaches 2.
|
||||
yield* settle(() => npm.peak > 1, 50).pipe(Effect.ignore)
|
||||
const peak = npm.peak
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
npm.gate = undefined
|
||||
yield* TestClock.adjust("100 millis")
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(peak).toBe(1)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { Deferred, Duration, Effect, Layer, LayerMap, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
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 { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -14,19 +16,35 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { advance } from "../lib/clock"
|
||||
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) =>
|
||||
const greeter = (command: string, plugin: string = id) =>
|
||||
define({
|
||||
id,
|
||||
id: plugin,
|
||||
effect: (ctx) =>
|
||||
ctx.command.transform((editor) => editor.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
|
||||
const source = { activations: 0 }
|
||||
const sourceLayer = Layer.succeed(
|
||||
ConfigPluginSource.Service,
|
||||
ConfigPluginSource.Service.of({
|
||||
operations: () =>
|
||||
Effect.sync(() => {
|
||||
source.activations++
|
||||
return []
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -37,6 +55,8 @@ const instances = Layer.effect(
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
ConfigPluginSource.node.replace(sourceLayer),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
@@ -51,6 +71,7 @@ const instances = Layer.effect(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
@@ -94,4 +115,117 @@ describe("PluginSupervisor", () => {
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("activates the initial generation without waiting for the reload debounce", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
// The TestClock never advances here, so any timer between boot and the first activation would hang this.
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("instance-greet")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes every 24 hours without adding an immediate reload", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* TestClock.adjust("23 hours")
|
||||
expect(source.activations).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("1 hour")
|
||||
yield* advance(() => source.activations === 2)
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
yield* TestClock.adjust("24 hours")
|
||||
yield* advance(() => source.activations === 3)
|
||||
yield* plugins.awaitActivation
|
||||
expect(source.activations).toBe(3)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads for a trigger published while the initial generation is activating", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "gated",
|
||||
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
}),
|
||||
)
|
||||
let late = false
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* Deferred.await(entered)
|
||||
// Generation 0 is mid-setup: the reload feed must already be subscribed for this update to count.
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "late",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "late-greet", execute: () => Effect.void }))
|
||||
.pipe(Effect.tap(() => Effect.sync(() => (late = true)))),
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* advance(() => late)
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("late-greet")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(source.activations).toBe(1)
|
||||
|
||||
yield* sdk.register(greeter("greet-a", "a"))
|
||||
yield* sdk.register(greeter("greet-b", "b"))
|
||||
yield* sdk.register(greeter("greet-c", "c"))
|
||||
yield* advance(() => source.activations > 1)
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("greet-a")).toBeDefined()
|
||||
expect(yield* commands.get("greet-c")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
import path from "path"
|
||||
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json")
|
||||
process.env.OPENCODE_DISABLE_MODELS_FETCH = "true"
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, Scope, Stream } from "effect"
|
||||
import { z } from "zod"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
|
||||
Location.node.replace(Layer.succeed(Location.Service, location(ref))),
|
||||
]),
|
||||
)
|
||||
const Echo = Rpc.define({
|
||||
id: "test.rpc",
|
||||
methods: { echo: { input: z.string(), output: z.string() } },
|
||||
events: { updated: { schema: z.object({ text: z.string() }) } },
|
||||
})
|
||||
|
||||
describe("Rpc", () => {
|
||||
it.effect("creates handles before registration and resolves on every execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const client = rpc.client(Echo)
|
||||
const request = client.echo("hello")
|
||||
expect(yield* request.pipe(Effect.flip)).toEqual({
|
||||
type: "rpc.unavailable",
|
||||
message: "RPC is unavailable: test.rpc",
|
||||
})
|
||||
|
||||
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
|
||||
expect(yield* request).toBe("hello")
|
||||
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
|
||||
expect(yield* request).toBe("hello!")
|
||||
expect(yield* rpc.call(Echo.id, "missing", "hello").pipe(Effect.flip)).toEqual({
|
||||
type: "rpc.method_not_found",
|
||||
message: "Unknown RPC method: test.rpc.missing",
|
||||
})
|
||||
expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({
|
||||
type: "rpc.method_not_found",
|
||||
message: "Unknown RPC method: test.rpc.toString",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const client = rpc.client(Echo)
|
||||
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
|
||||
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
|
||||
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
|
||||
expect(yield* client.echo("hello")).toBe("third")
|
||||
yield* second.dispose
|
||||
expect(yield* client.echo("hello")).toBe("third")
|
||||
yield* third.dispose
|
||||
expect(yield* client.echo("hello")).toBe("first")
|
||||
yield* third.dispose
|
||||
expect(yield* client.echo("hello")).toBe("first")
|
||||
yield* first.dispose
|
||||
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes registrations when their owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
|
||||
const scope = yield* Scope.make()
|
||||
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates inputs before running handlers and validates returned results", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const received: string[] = []
|
||||
yield* rpc.register(Echo, {
|
||||
echo: (value) =>
|
||||
Effect.sync(() => {
|
||||
received.push(value)
|
||||
return value
|
||||
}),
|
||||
})
|
||||
expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true)
|
||||
expect(received).toEqual([])
|
||||
|
||||
const Checked = Rpc.define({
|
||||
id: "checked",
|
||||
methods: { echo: { input: z.string(), output: z.string().min(3) } },
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
|
||||
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves local transport values to the declared schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Identity = Rpc.define({
|
||||
id: "identity",
|
||||
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Identity, { echo: Effect.succeed })
|
||||
const value = new Date(0)
|
||||
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const counts = { input: 0, output: 0, event: 0 }
|
||||
const Transformed = Rpc.define({
|
||||
id: "transformed",
|
||||
methods: {
|
||||
count: {
|
||||
input: z.string().transform((value) => {
|
||||
counts.input++
|
||||
return Number(value)
|
||||
}),
|
||||
output: z.number().transform((value) => {
|
||||
counts.output++
|
||||
return String(value)
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {
|
||||
counted: {
|
||||
schema: z.object({ count: z.number() }).transform(({ count }) => {
|
||||
counts.event++
|
||||
return { text: String(count) }
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
|
||||
const client = rpc.client(Transformed)
|
||||
expect(yield* client.count("41")).toBe("42")
|
||||
const events = yield* client.events
|
||||
.subscribe("counted")
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* registration.events.emit("counted", { count: 42 })
|
||||
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
|
||||
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Codec = Rpc.define({
|
||||
id: "codec",
|
||||
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
|
||||
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
|
||||
})
|
||||
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
|
||||
expect(yield* rpc.call(Codec.id, "count", "41")).toBe("42")
|
||||
expect(yield* rpc.client(Codec).count("41")).toBe(42)
|
||||
const events = yield* rpc
|
||||
.client(Codec)
|
||||
.events.subscribe("counted")
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* registration.events.emit("counted", { count: 42 })
|
||||
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates declared error data and decodes it for local clients", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Failing = Rpc.define({
|
||||
id: "failing",
|
||||
methods: {
|
||||
standard: {
|
||||
input: z.undefined(),
|
||||
output: z.string(),
|
||||
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
|
||||
},
|
||||
effect: {
|
||||
input: Schema.Undefined,
|
||||
output: Schema.String,
|
||||
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Failing, {
|
||||
standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
|
||||
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
|
||||
})
|
||||
|
||||
expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({
|
||||
type: "missing",
|
||||
message: "Missing",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
|
||||
type: "missing",
|
||||
message: "Missing",
|
||||
data: { attempts: 2 },
|
||||
})
|
||||
expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({
|
||||
type: "invalid",
|
||||
message: "Invalid",
|
||||
data: { count: "3" },
|
||||
})
|
||||
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
|
||||
type: "invalid",
|
||||
message: "Invalid",
|
||||
data: { count: 3 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails in-process callers with a typed rpc.internal error for defects and undeclared errors", () => {
|
||||
const logged: unknown[] = []
|
||||
const logger = Logger.make((entry) => {
|
||||
if (entry.logLevel === "Error") logged.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
dies: { input: z.undefined(), output: z.string() },
|
||||
throws: { input: z.undefined(), output: z.string() },
|
||||
raw: { input: z.undefined(), output: z.string() },
|
||||
undeclared: { input: z.undefined(), output: z.string(), errors: { known: z.object({}) } },
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Broken, {
|
||||
dies: () => Effect.die(new Error("handler defect")),
|
||||
throws: () => {
|
||||
throw new Error("handler threw")
|
||||
},
|
||||
// @ts-expect-error the Promise adapter fails with raw thrown values that the types otherwise forbid
|
||||
raw: () => Effect.fail(new Error("raw failure")),
|
||||
// @ts-expect-error undeclared error names are rejected statically; the runtime contract is under test
|
||||
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
|
||||
})
|
||||
const client = rpc.client(Broken)
|
||||
const internal = { type: "rpc.internal" as const, message: "RPC call failed" }
|
||||
|
||||
// Every path yields the same typed failure the HTTP handler already exposes, so callers can recover.
|
||||
expect(yield* client.dies().pipe(Effect.flip)).toEqual(internal)
|
||||
expect(yield* client.throws().pipe(Effect.flip)).toEqual(internal)
|
||||
expect(yield* client.raw().pipe(Effect.flip)).toEqual(internal)
|
||||
expect(yield* rpc.call(Broken.id, "dies", undefined).pipe(Effect.flip)).toEqual(internal)
|
||||
expect(yield* client.undeclared().pipe(Effect.flip)).toEqual({
|
||||
type: "rpc.internal",
|
||||
message: "Undeclared RPC error: unknown",
|
||||
})
|
||||
expect(
|
||||
yield* client.dies().pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error.type === "rpc.internal",
|
||||
(error) => Effect.succeed(`recovered:${error.type}`),
|
||||
),
|
||||
),
|
||||
).toBe("recovered:rpc.internal")
|
||||
// Each conversion logs its cause once so the detail is not lost.
|
||||
expect(logged).toHaveLength(6)
|
||||
expect(logged[0]).toEqual([
|
||||
"rpc handler failed",
|
||||
{ rpc: "broken", method: "dies", error: new Error("handler defect") },
|
||||
])
|
||||
expect(logged[2]).toEqual([
|
||||
"rpc handler failed",
|
||||
{ rpc: "broken", method: "raw", error: new Error("raw failure") },
|
||||
])
|
||||
}).pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
|
||||
})
|
||||
|
||||
it.effect("keeps other event consumers running after one subscription ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
|
||||
const client = rpc.client(Echo)
|
||||
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const second = yield* client.events
|
||||
.subscribe("updated")
|
||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* registration.events.emit("updated", { text: "first" })
|
||||
const received = yield* Fiber.join(first)
|
||||
expect(received.map((event) => event.data.text)).toEqual(["first"])
|
||||
Reflect.set(received[0].location, "directory", "/consumer-mutated")
|
||||
yield* registration.events.emit("updated", { text: "second" })
|
||||
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Raw = Rpc.define({
|
||||
id: "raw",
|
||||
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
|
||||
events: {
|
||||
counted: {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { count: { type: "integer", minimum: 1 } },
|
||||
required: ["count"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
|
||||
expect(yield* rpc.call(Raw.id, "count", 42)).toBe(42)
|
||||
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports methods with no input and no returned value", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const Empty = Rpc.define({
|
||||
id: "empty",
|
||||
methods: { ping: { input: z.undefined(), output: z.undefined() } },
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Empty, { ping: () => Effect.undefined })
|
||||
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps in-flight calls on their original implementation after removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const registration = yield* rpc.register(Echo, {
|
||||
echo: (value) =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
|
||||
})
|
||||
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
yield* registration.dispose
|
||||
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* Fiber.join(call)).toBe("original")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
yield* rpc.register(Echo, {
|
||||
echo: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
|
||||
),
|
||||
})
|
||||
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(call)
|
||||
yield* Deferred.await(stopped)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
const bus = yield* Bus.Service
|
||||
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
|
||||
const otherContext = yield* Layer.build(
|
||||
LayerNode.compile(Rpc.node, {
|
||||
replacements: [
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Location.node.replace(Layer.succeed(Location.Service, location(otherRef))),
|
||||
],
|
||||
}).pipe(Layer.fresh),
|
||||
)
|
||||
const other = Context.get(otherContext, Rpc.Service)
|
||||
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
|
||||
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
|
||||
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
|
||||
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
|
||||
expect(yield* other.client(Echo).echo("hello")).toBe("second")
|
||||
|
||||
const all: Event.Payload[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
all.push(event)
|
||||
}),
|
||||
)
|
||||
const localEvents = yield* rpc
|
||||
.client(Echo)
|
||||
.events.subscribe("updated")
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
const otherEvents = yield* other
|
||||
.client(Echo)
|
||||
.events.subscribe("updated")
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* second.events.emit("updated", { text: "second" })
|
||||
yield* first.events
|
||||
.emit("updated", { text: "first" })
|
||||
.pipe(Effect.provideService(Location.Service, location(otherRef)))
|
||||
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
|
||||
type: "rpc.test.rpc.updated",
|
||||
data: { text: "first" },
|
||||
location: ref,
|
||||
})
|
||||
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
|
||||
type: "rpc.test.rpc.updated",
|
||||
data: { text: "second" },
|
||||
location: otherRef,
|
||||
})
|
||||
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
|
||||
yield* unsubscribe
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -24,6 +25,7 @@ const it = testEffect(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Expected } from "./lib/session-message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { promptLocationNode } from "./fixture/prompt-location"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
@@ -61,7 +62,11 @@ const it = testEffect(
|
||||
const liveIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
@@ -227,7 +232,7 @@ describe("Session.create", () => {
|
||||
).toMatchObject({
|
||||
time_created: before.time_created,
|
||||
time_updated: before.time_updated,
|
||||
time_suspended: before.time_suspended,
|
||||
execution_claimed_at: before.execution_claimed_at,
|
||||
resume_attempts: before.resume_attempts,
|
||||
})
|
||||
// Repeated resolution announces the directory's identity exactly once.
|
||||
|
||||
@@ -63,10 +63,10 @@ describe("SessionExecution lifecycle", () => {
|
||||
const parent = Session.ID.make("ses_recover_parent")
|
||||
const child = Session.ID.make("ses_recover_child")
|
||||
const idle = Session.ID.make("ses_recover_idle")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
|
||||
yield* seedSessions(database, [idle])
|
||||
// Children recover through background Job records, never through the root claim sweep.
|
||||
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
|
||||
yield* seedSessions(database, [child], { execution_claimed_at: Date.now(), parent_id: parent })
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([parent])
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
|
||||
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, sessionIDs, { execution_claimed_at: Date.now() })
|
||||
|
||||
const fourStarted = yield* Deferred.make<void>()
|
||||
const started: Session.ID[] = []
|
||||
@@ -251,7 +251,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.make("ses_resume_first")
|
||||
const second = Session.ID.make("ses_resume_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [first, second], { execution_claimed_at: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const bothDraining = yield* Deferred.make<void>()
|
||||
@@ -297,7 +297,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = Session.ID.make("ses_resume_exhausted")
|
||||
// A claim from a dead process, already resumed twice without completing.
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
|
||||
|
||||
const drained: string[] = []
|
||||
const failures: SessionEvent.Execution.Failed[] = []
|
||||
@@ -322,7 +322,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_resume_counted")
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now() })
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
@@ -388,7 +388,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_background_recovery_parent")
|
||||
const child = Session.ID.make("ses_background_recovery_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [child], { parent_id: parent, execution_claimed_at: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [
|
||||
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
])
|
||||
@@ -600,7 +600,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const store = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_claimed_parent")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
|
||||
|
||||
const observed = yield* Deferred.make<string[]>()
|
||||
@@ -641,7 +641,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_shell_recovery_exhausted")
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
|
||||
yield* seedBackground(jobs, sessionID, [
|
||||
{ id: "call-exhausted-shell", shellID: "sh_exhausted", command: "sleep 60" },
|
||||
])
|
||||
@@ -688,7 +688,7 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], {
|
||||
parent_id: parent,
|
||||
time_suspended: Date.now(),
|
||||
execution_claimed_at: Date.now(),
|
||||
resume_attempts: resumeAttempts,
|
||||
})
|
||||
const shell = seedBackground(jobs, child, [
|
||||
@@ -782,8 +782,8 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_subagent_recovery_parent")
|
||||
const child = Session.ID.make("ses_subagent_recovery_child")
|
||||
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, execution_claimed_at: Date.now() })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
@@ -959,7 +959,7 @@ describe("SessionRestart background recovery", () => {
|
||||
Session.ID.make("ses_subagent_budget_child_1"),
|
||||
Session.ID.make("ses_subagent_budget_child_2"),
|
||||
]
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, children, { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
for (const child of children) {
|
||||
@@ -1033,7 +1033,11 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_subagent_exhausted_parent")
|
||||
const child = Session.ID.make("ses_subagent_exhausted_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [child], {
|
||||
parent_id: parent,
|
||||
execution_claimed_at: Date.now(),
|
||||
resume_attempts: 2,
|
||||
})
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
@@ -1273,7 +1277,9 @@ function seedInbox(
|
||||
function seedSessions(
|
||||
database: Database.Service["Service"],
|
||||
sessionIDs: ReadonlyArray<Session.ID>,
|
||||
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
|
||||
values: Partial<
|
||||
Pick<typeof SessionTable.$inferInsert, "execution_claimed_at" | "resume_attempts" | "parent_id">
|
||||
> = {},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* database.db
|
||||
@@ -1302,7 +1308,7 @@ function seedSessions(
|
||||
|
||||
function claims(database: Database.Service["Service"]) {
|
||||
return database.db
|
||||
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
|
||||
.select({ id: SessionTable.id, claimed: SessionTable.execution_claimed_at })
|
||||
.from(SessionTable)
|
||||
.all()
|
||||
.pipe(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
@@ -25,7 +26,7 @@ import { globalProjectNode } from "./lib/project"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
const itWithActiveExecution = testEffect(
|
||||
@@ -47,7 +48,7 @@ const itWithActiveExecution = testEffect(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(Location.boundNode(ref), {
|
||||
replacements: [Project.node.replace(globalProjectNode)],
|
||||
replacements: [Project.node.replace(globalProjectNode), offlineModels],
|
||||
}),
|
||||
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
|
||||
@@ -549,19 +549,19 @@ describe("SessionProjector", () => {
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const suspended = () =>
|
||||
const claim = () =>
|
||||
db
|
||||
.select({ timeSuspended: SessionTable.time_suspended })
|
||||
.select({ executionClaimedAt: SessionTable.execution_claimed_at })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
expect((yield* claim())?.executionClaimedAt).toBeNull()
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
expect((yield* claim())?.executionClaimedAt).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
|
||||
const closed: Session.ID[] = []
|
||||
@@ -50,6 +51,7 @@ const it = testEffect(
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionModelTransport.node.replace(transport),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -33,6 +34,7 @@ const it = testEffect(
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { location } from "./fixture/location"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -59,6 +60,7 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))),
|
||||
offlineModels,
|
||||
]).pipe(Layer.provideMerge(controlLayer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ for (const fixture of [
|
||||
executeTool: () =>
|
||||
Effect.sync(() => {
|
||||
executions++
|
||||
return { content: "Completed tool" }
|
||||
return { content: [{ type: "text", text: "Completed tool" }] }
|
||||
}),
|
||||
},
|
||||
retry: (_cause, _error, retry) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import type { Tool } from "@opencode-ai/core/tool"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
@@ -36,7 +37,7 @@ describe("ToolOutput", () => {
|
||||
(service, fs) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { items: [1, 2, 3] }
|
||||
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
|
||||
const result = yield* service.truncate({ output, content: [{ type: "text", text: "one\ntwo\nthree" }] })
|
||||
expect(result.output).toBe(output)
|
||||
expect(result.metadata).toMatchObject({ truncated: true })
|
||||
const outputPath = result.metadata?.outputPath
|
||||
@@ -56,7 +57,7 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{
|
||||
@@ -91,8 +92,9 @@ describe("ToolOutput", () => {
|
||||
it.live("skips results that report a truncation state", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
|
||||
const content: Tool.NormalizedResult["content"] = [{ type: "text", text: "one\ntwo" }]
|
||||
const truncated = { content, metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content, metadata: { truncated: false, source: "tool" } }
|
||||
expect(yield* output.truncate(truncated)).toBe(truncated)
|
||||
expect(yield* output.truncate(retained)).toBe(retained)
|
||||
}),
|
||||
@@ -112,8 +114,8 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
|
||||
content: "one\ntwo\n",
|
||||
expect(yield* output.truncate({ content: [{ type: "text", text: "one\ntwo\n" }] })).toEqual({
|
||||
content: [{ type: "text", text: "one\ntwo\n" }],
|
||||
metadata: { truncated: false },
|
||||
})
|
||||
}),
|
||||
@@ -125,7 +127,7 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\n" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import type { Permission } from "@opencode-ai/core/permission"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { z } from "zod"
|
||||
@@ -35,7 +37,9 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
})
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)])
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
|
||||
Image.node.replace(imageStore),
|
||||
])
|
||||
const it = testEffect(registryLayer)
|
||||
const identity = {
|
||||
agent: Agent.ID.make("build"),
|
||||
@@ -843,6 +847,36 @@ describe("Tool", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "string", content: "hooked", text: "hooked" },
|
||||
{ name: "empty string", content: "", text: "" },
|
||||
{ name: "missing content", content: undefined, text: '{"text":"hooked"}' },
|
||||
{ name: "empty content array", content: [], text: '{"text":"hooked"}' },
|
||||
].forEach((input) => {
|
||||
it.effect(`normalizes ${input.name} after the final tool hook`, () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* hooks.register("tool", "execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status !== "completed") return
|
||||
event.result = {
|
||||
output: { text: "hooked" },
|
||||
content: input.content,
|
||||
metadata: { source: "hook" },
|
||||
}
|
||||
}),
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(yield* snapshot.execute(call("echo"))).toEqual({
|
||||
output: { text: "hooked" },
|
||||
content: [{ type: "text", text: input.text }],
|
||||
metadata: { source: "hook" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("normalizes image tool output once and drops unresizable images", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { Expected } from "./lib/session-message"
|
||||
@@ -155,6 +156,7 @@ const replacements = [
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Permission.node.replace(permission),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(
|
||||
@@ -165,6 +167,7 @@ const permissionIt = testEffect(
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
PluginSupervisor.node.replace(shellPluginSupervisor),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
@@ -120,6 +121,7 @@ const nodes = LayerNode.group([
|
||||
const replacements = [
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(
|
||||
@@ -128,6 +130,7 @@ const it = testEffect(
|
||||
const completionIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
PluginSupervisor.node.replace(subagentPluginSupervisor),
|
||||
LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })),
|
||||
SessionRunnerModel.node.replace(
|
||||
|
||||
@@ -65,7 +65,7 @@ const session = (
|
||||
idle_outcome: null,
|
||||
time_compacting: 3,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
...overrides,
|
||||
})
|
||||
@@ -870,6 +870,7 @@ describe("V1Migration database workflow", () => {
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
|
||||
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
|
||||
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
|
||||
UPDATE session SET time_suspended = 1234 WHERE id = 'ses_next';
|
||||
`)
|
||||
source.close()
|
||||
|
||||
@@ -901,6 +902,9 @@ describe("V1Migration database workflow", () => {
|
||||
agent: "build",
|
||||
model: '{"id":"model","providerID":"provider"}',
|
||||
})
|
||||
expect(
|
||||
yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ execution_claimed_at: 1234, resume_attempts: 0 })
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory })
|
||||
@@ -988,8 +992,8 @@ describe("V1Migration database workflow", () => {
|
||||
const database = yield* Database.Service
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, time_suspended: null })
|
||||
yield* database.db.get(sql`SELECT fork_boundary, execution_claimed_at FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, execution_claimed_at: null })
|
||||
expect(
|
||||
yield* database.db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
} from "@opencode-ai/app/wsl/types"
|
||||
import { Effect } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { WSL_SERVERS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import {
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
@@ -38,8 +36,8 @@ type WslServersControllerOptions = {
|
||||
spawnSidecar: SpawnSidecar
|
||||
installCli: (distro: string, cli: WslCliBuild) => Promise<void>
|
||||
installDistro: (distro: string) => Promise<void>
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
readServers: () => WslServerConfig[]
|
||||
writeServers: (servers: WslServerConfig[]) => void
|
||||
probeDistro?: typeof probeWslDistro
|
||||
resolveCli?: typeof resolveWslCli
|
||||
readCliVersion?: typeof readWslCliVersion
|
||||
@@ -60,8 +58,6 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const starts = new Map<string, symbol>()
|
||||
let closed = false
|
||||
const readServers = options.readServers ?? readPersistedServers
|
||||
const writeServers = options.writeServers ?? writePersistedServers
|
||||
const probeDistro = options.probeDistro ?? probeWslDistro
|
||||
|
||||
const emit = () => {
|
||||
@@ -79,7 +75,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readServers()
|
||||
const persisted = options.readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
||||
return {
|
||||
@@ -291,7 +287,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
writeServers([...readServers(), config])
|
||||
options.writeServers([...options.readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
@@ -302,8 +298,8 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
async removeServer(id: string) {
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
await stopServer(id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
writeServers(remaining)
|
||||
const remaining = options.readServers().filter((item) => item.id !== id)
|
||||
options.writeServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
...(distro ? removeDistroState(state, distro) : {}),
|
||||
@@ -334,35 +330,6 @@ function initialState(): WslServersState {
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedServers(): WslServerConfig[] {
|
||||
const store = getStore()
|
||||
const existing = store.get(WSL_SERVERS_KEY)
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap(normalizePersistedServer)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function writePersistedServers(servers: WslServerConfig[]) {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [
|
||||
{
|
||||
id,
|
||||
distro,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function cliCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Wsl from "./start"
|
||||
import { Context, Effect, Exit, FileSystem, Layer, Path } from "effect"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { WSL_SERVERS_KEY } from "../storage/keys"
|
||||
import { WslIpc } from "./ipc"
|
||||
|
||||
export interface Interface extends WslIpc.Interface {
|
||||
@@ -27,7 +28,8 @@ export const layer = Layer.effect(
|
||||
const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
|
||||
if (process.platform !== "win32") return { ...WslIpc.create(), stop: Effect.void }
|
||||
|
||||
const { createWslServersController } = yield* Effect.promise(() => import("./servers"))
|
||||
const { createWslServersController, wslServerIdForDistro } = yield* Effect.promise(() => import("./servers"))
|
||||
const { getStore } = yield* Effect.promise(() => import("../storage/store"))
|
||||
const { spawnWslSidecar } = yield* Effect.promise(() => import("./sidecar"))
|
||||
const { installWslCli, installWslDistro } = yield* Effect.promise(() => import("./runtime"))
|
||||
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>()
|
||||
@@ -36,6 +38,21 @@ const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
|
||||
const local = cli.wslBuild
|
||||
const controller = yield* createWslServersController({
|
||||
cli: { version: cli.version },
|
||||
readServers: () => {
|
||||
const existing = getStore().get(WSL_SERVERS_KEY)
|
||||
if (!existing || typeof existing !== "object") return []
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap((value: unknown) => {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [{ id, distro }]
|
||||
})
|
||||
},
|
||||
writeServers: (servers) => getStore().set(WSL_SERVERS_KEY, { servers }),
|
||||
installDistro: (distro) => run(installWslDistro(distro)),
|
||||
installCli: local
|
||||
? async (distro) => {
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./host": "./src/host.ts",
|
||||
"./tui": "./src/tui/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -23,6 +25,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:"
|
||||
@@ -53,6 +56,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export * as Host from "./host.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export interface Target {
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export interface Entrypoints {
|
||||
readonly server?: string
|
||||
readonly tui?: string
|
||||
readonly rpc?: string
|
||||
}
|
||||
|
||||
export function resolve(target: Target): Entrypoints {
|
||||
const entry = (subpaths: readonly string[]) => {
|
||||
for (const subpath of subpaths) {
|
||||
const specifier = target.name
|
||||
? [target.name, subpath].filter(Boolean).join("/")
|
||||
: path.resolve(target.directory, subpath || "index")
|
||||
try {
|
||||
return resolveModule(specifier, target.directory)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!("code" in error) ||
|
||||
![
|
||||
"ENOENT",
|
||||
"ENOTDIR",
|
||||
"MODULE_NOT_FOUND",
|
||||
"ERR_MODULE_NOT_FOUND",
|
||||
"ERR_PACKAGE_PATH_NOT_EXPORTED",
|
||||
"ERR_UNSUPPORTED_DIR_IMPORT",
|
||||
].includes(String(error.code))
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
|
||||
}
|
||||
|
||||
export function load(entrypoint: string): Promise<unknown> {
|
||||
return importModule(entrypoint)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Host } from "../src/host.js"
|
||||
|
||||
// Every entrypoint throws if evaluated: resolution must never execute plugins.
|
||||
const source = 'throw new Error("Plugin code must not run during resolution")'
|
||||
const name = "@fixture/plugin"
|
||||
|
||||
async function fixture(files: Record<string, string>, installed = false) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
|
||||
const directory = installed ? path.join(root, "node_modules", name) : root
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([file, content]) => {
|
||||
await mkdir(path.dirname(path.join(directory, file)), { recursive: true })
|
||||
await writeFile(path.join(directory, file), content)
|
||||
}),
|
||||
)
|
||||
return {
|
||||
target: { directory, ...(installed ? { name } : {}) },
|
||||
url: (file: string) => pathToFileURL(path.join(directory, file)).href,
|
||||
[Symbol.asyncDispose]: () => rm(root, { recursive: true, force: true }),
|
||||
}
|
||||
}
|
||||
|
||||
describe("Host.resolve", () => {
|
||||
it("resolves conventional entrypoints without package.json", async () => {
|
||||
await using plugin = await fixture({ "index.ts": source, "tui.tsx": source, "rpc.ts": source })
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("index.ts"),
|
||||
tui: plugin.url("tui.tsx"),
|
||||
rpc: plugin.url("rpc.ts"),
|
||||
})
|
||||
})
|
||||
|
||||
it("resolves a TUI-only directory without an index or package.json", async () => {
|
||||
await using plugin = await fixture({ "tui.tsx": source })
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: undefined,
|
||||
tui: plugin.url("tui.tsx"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
for (const main of [undefined, "lib/backend.js"]) {
|
||||
it(`resolves packages without exports using ${main ?? "the default index"}`, async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({ name, main }),
|
||||
[main ?? "index.js"]: source,
|
||||
"tui.js": source,
|
||||
"rpc.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url(main ?? "index.js"),
|
||||
tui: plugin.url("tui.js"),
|
||||
rpc: plugin.url("rpc.js"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it("resolves local conventional entrypoints with package.json but no exports", async () => {
|
||||
await using plugin = await fixture({
|
||||
"package.json": JSON.stringify({ name, type: "module" }),
|
||||
"index.js": source,
|
||||
"tui.js": source,
|
||||
"rpc.js": source,
|
||||
})
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("index.js"),
|
||||
tui: plugin.url("tui.js"),
|
||||
rpc: plugin.url("rpc.js"),
|
||||
})
|
||||
})
|
||||
|
||||
it("honors exports and prefers the explicit server entrypoint over the root", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({
|
||||
name,
|
||||
exports: {
|
||||
".": "./dist/root.js",
|
||||
"./server": "./dist/backend.js",
|
||||
"./tui": "./dist/terminal.js",
|
||||
"./rpc": "./dist/contract.js",
|
||||
},
|
||||
}),
|
||||
"dist/root.js": source,
|
||||
"dist/backend.js": source,
|
||||
"dist/terminal.js": source,
|
||||
"dist/contract.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("dist/backend.js"),
|
||||
tui: plugin.url("dist/terminal.js"),
|
||||
rpc: plugin.url("dist/contract.js"),
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to the root export and uses import rather than require conditions", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({
|
||||
name,
|
||||
exports: {
|
||||
".": { import: "./dist/server.mjs", require: "./dist/server.cjs" },
|
||||
"./tui": { import: "./dist/tui.mjs", require: "./dist/tui.cjs" },
|
||||
},
|
||||
}),
|
||||
"dist/server.mjs": source,
|
||||
"dist/server.cjs": source,
|
||||
"dist/tui.mjs": source,
|
||||
"dist/tui.cjs": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: plugin.url("dist/server.mjs"),
|
||||
tui: plugin.url("dist/tui.mjs"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("supports TUI-only exports without falling back to unexported files", async () => {
|
||||
await using plugin = await fixture(
|
||||
{
|
||||
"package.json": JSON.stringify({ name, exports: { "./tui": "./dist/terminal.js" } }),
|
||||
"dist/terminal.js": source,
|
||||
"index.js": source,
|
||||
"server.js": source,
|
||||
"rpc.js": source,
|
||||
},
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), {
|
||||
server: undefined,
|
||||
tui: plugin.url("dist/terminal.js"),
|
||||
rpc: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("does not return an exported entrypoint whose file is missing", async () => {
|
||||
await using plugin = await fixture(
|
||||
{ "package.json": JSON.stringify({ name, exports: { "./tui": "./missing.js" } }) },
|
||||
true,
|
||||
)
|
||||
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`.
|
||||
- Schema values should be serializable contract definitions, not service implementations or runtime registries.
|
||||
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
|
||||
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.updated` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
|
||||
- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports.
|
||||
|
||||
## Current Versus V1
|
||||
|
||||
@@ -42,12 +42,8 @@ export const Info = Schema.Struct({
|
||||
state: State,
|
||||
}).annotate({ identifier: "Plugin.Info" })
|
||||
|
||||
const Added = ephemeral({
|
||||
type: "plugin.added",
|
||||
schema: { id: ID },
|
||||
})
|
||||
const Updated = ephemeral({
|
||||
type: "plugin.updated",
|
||||
schema: {},
|
||||
})
|
||||
export const Event = { Added, Updated, Definitions: inventory(Added, Updated) }
|
||||
export const Event = { Updated, Definitions: inventory(Updated) }
|
||||
|
||||
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
return output === undefined ? {} : { output }
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
|
||||
error.type === "rpc.invalid_output"
|
||||
? new RpcInternalError({ type: error.type, message: error.message })
|
||||
: new RpcError({
|
||||
type: error.type,
|
||||
@@ -22,10 +22,12 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
}),
|
||||
),
|
||||
// Core already converts handler defects; this net catches anything else without echoing its text.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
|
||||
Effect.catchDefect((error) =>
|
||||
Effect.fail(
|
||||
new RpcInternalError({
|
||||
type: "rpc.internal",
|
||||
message: error instanceof Error ? error.message : "RPC call failed",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ export const startServer = Effect.fnUntraced(function* (directory: string) {
|
||||
database: { path: ":memory:" },
|
||||
config: { directory },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
return {
|
||||
base: HttpServer.formatAddress(server.address),
|
||||
|
||||
@@ -47,6 +47,7 @@ it.live("uses base configuration without depending on process.cwd()", () =>
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{ overrides: [Generate.node.replace(generate)] },
|
||||
)
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } 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"
|
||||
|
||||
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
|
||||
|
||||
const authorization = `Basic ${btoa("opencode:secret")}`
|
||||
|
||||
const fixture = Effect.fn(function* (plugins: readonly Parameters<SdkPlugins.Interface["register"]>[0][]) {
|
||||
const tmp = yield* tmpdirScoped("opencode-rpc-server-")
|
||||
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:" },
|
||||
config: { directory: config, project: false, content: "{}" },
|
||||
fs: { filewatcher: false },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* Effect.forEach(plugins, (plugin) => sdk.register(plugin))
|
||||
const locations = Context.get(context, LocationServiceMap.Service)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
handler,
|
||||
boot: (directory: string) =>
|
||||
Plugin.awaitActivation.pipe(
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
call: (
|
||||
route: string,
|
||||
body: unknown = {},
|
||||
options: { directory?: string; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||
) =>
|
||||
Effect.promise(() => {
|
||||
const url = new URL(`/api/rpc/${route}`, "http://opencode.local")
|
||||
if (options.directory) url.searchParams.set("location[directory]", options.directory)
|
||||
return handler(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", ...options.headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
it.live("dispatches RPC wrappers with query, header and default locations and generic failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const Echo = Rpc.define({
|
||||
id: "transport.echo",
|
||||
methods: {
|
||||
echo: { input: Schema.String, output: Schema.String },
|
||||
json: { input: Schema.Json, output: Schema.Json },
|
||||
empty: { input: Schema.Undefined, output: Schema.Undefined },
|
||||
fail: {
|
||||
input: Schema.Undefined,
|
||||
output: Schema.String,
|
||||
errors: { rejected: Schema.Struct({ reason: Schema.String }) },
|
||||
},
|
||||
defect: { input: Schema.Undefined, output: Schema.String },
|
||||
undeclared: { input: Schema.Undefined, output: Schema.String },
|
||||
invalid: { input: Schema.Undefined, output: { type: "string" } },
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
const server = yield* fixture([
|
||||
define({
|
||||
id: "transport-implementer",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.rpc.register(Echo, {
|
||||
echo: (input) => Effect.succeed(`${ctx.location.directory}:${input}`),
|
||||
json: (input) => Effect.succeed(input),
|
||||
empty: () => Effect.succeed(undefined),
|
||||
fail: (_input, context) =>
|
||||
Effect.fail(context.error("rejected", "handler failed", { reason: "declared" })),
|
||||
defect: () => Effect.die(new Error("secret handler detail")),
|
||||
// @ts-expect-error undeclared error names are rejected statically; the runtime contract is under test
|
||||
undeclared: (_input, context) => Effect.fail(context.error("surprise", "undeclared failure")),
|
||||
invalid: () => Effect.succeed(123),
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
}),
|
||||
])
|
||||
yield* server.boot(server.first)
|
||||
yield* server.boot(server.second)
|
||||
yield* server.boot(process.cwd())
|
||||
const selected = yield* server.call(
|
||||
"transport.echo/echo",
|
||||
{ input: "selected" },
|
||||
{
|
||||
directory: server.first,
|
||||
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
|
||||
},
|
||||
)
|
||||
expect(selected.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => selected.json())).toEqual({ output: `${server.first}:selected` })
|
||||
const header = yield* server.call(
|
||||
"transport.echo/echo",
|
||||
{ input: "header" },
|
||||
{
|
||||
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
|
||||
},
|
||||
)
|
||||
expect(yield* Effect.promise(() => header.json())).toEqual({ output: `${server.second}:header` })
|
||||
const fallback = yield* server.call("transport.echo/echo", { input: "default" })
|
||||
expect(yield* Effect.promise(() => fallback.json())).toEqual({ output: `${process.cwd()}:default` })
|
||||
const empty = yield* server.call("transport.echo/empty")
|
||||
expect(empty.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => empty.json())).toEqual({})
|
||||
yield* Effect.forEach([null, false, 42, ["array"], { location: "ordinary input" }], (input) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* server.call("transport.echo/json", { input })
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({ output: input })
|
||||
}),
|
||||
)
|
||||
const denied = yield* server.call("transport.echo/empty", {}, { headers: { authorization: "" } })
|
||||
expect(denied.status).toBe(401)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{
|
||||
route: "missing/echo",
|
||||
body: {},
|
||||
error: { type: "rpc.unavailable", message: "RPC is unavailable: missing" },
|
||||
},
|
||||
{
|
||||
route: "transport.echo/missing",
|
||||
body: {},
|
||||
error: { type: "rpc.method_not_found", message: "Unknown RPC method: transport.echo.missing" },
|
||||
},
|
||||
{
|
||||
route: "transport.echo/fail",
|
||||
body: {},
|
||||
error: { type: "rejected", message: "handler failed", data: { reason: "declared" } },
|
||||
},
|
||||
{ route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } },
|
||||
],
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* server.call(item.route, item.body)
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
_tag: "RpcError",
|
||||
message: expect.any(String),
|
||||
...item.error,
|
||||
})
|
||||
}),
|
||||
)
|
||||
// Handler exception text never reaches HTTP clients; the fixed message matches the in-process contract.
|
||||
const defect = yield* server.call("transport.echo/defect")
|
||||
expect(defect.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => defect.json())).toEqual({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.internal",
|
||||
message: "RPC call failed",
|
||||
})
|
||||
const undeclared = yield* server.call("transport.echo/undeclared")
|
||||
expect(undeclared.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => undeclared.json())).toEqual({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.internal",
|
||||
message: "Undeclared RPC error: surprise",
|
||||
})
|
||||
const invalid = yield* server.call("transport.echo/invalid")
|
||||
expect(invalid.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => invalid.json())).toMatchObject({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.invalid_output",
|
||||
message: expect.any(String),
|
||||
})
|
||||
const malformed = yield* server.call("transport.echo/echo", "not a wrapper")
|
||||
expect(malformed.status).toBe(400)
|
||||
expect(yield* Effect.promise(() => malformed.json())).toMatchObject({
|
||||
_tag: "InvalidRequestError",
|
||||
message: expect.any(String),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("request cancellation interrupts Effect RPC handlers and signals Promise RPC handlers", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const promiseStarted = Promise.withResolvers<void>()
|
||||
const promiseStopped = Promise.withResolvers<void>()
|
||||
const Blocking = Rpc.define({
|
||||
id: "blocking",
|
||||
methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } },
|
||||
events: {},
|
||||
})
|
||||
const PromiseBlocking = Rpc.define({
|
||||
id: "promise-blocking",
|
||||
methods: { wait: { input: { type: "null" }, output: { type: "null" } } },
|
||||
events: {},
|
||||
})
|
||||
const server = yield* fixture([
|
||||
define({
|
||||
id: "effect-blocking",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Blocking, {
|
||||
wait: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.ensuring(Deferred.succeed(stopped, undefined)),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
}),
|
||||
fromPromise({
|
||||
id: "promise-blocking",
|
||||
async setup(ctx) {
|
||||
await ctx.rpc.register(PromiseBlocking, {
|
||||
wait: (_input, call) =>
|
||||
new Promise<null>((resolve) => {
|
||||
promiseStarted.resolve()
|
||||
call.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
promiseStopped.resolve()
|
||||
resolve(null)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
},
|
||||
}),
|
||||
])
|
||||
yield* server.boot(server.first)
|
||||
const controller = new AbortController()
|
||||
const pending = yield* server
|
||||
.call(
|
||||
"blocking/wait",
|
||||
{},
|
||||
{
|
||||
directory: server.first,
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
controller.abort()
|
||||
yield* Deferred.await(stopped)
|
||||
expect((yield* Fiber.join(pending)).status).not.toBe(400)
|
||||
const promiseController = new AbortController()
|
||||
const promisePending = yield* server
|
||||
.call(
|
||||
"promise-blocking/wait",
|
||||
{ input: null },
|
||||
{
|
||||
directory: server.first,
|
||||
signal: promiseController.signal,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkScoped)
|
||||
yield* Effect.promise(() => promiseStarted.promise)
|
||||
promiseController.abort()
|
||||
yield* Effect.promise(() => promiseStopped.promise)
|
||||
expect((yield* Fiber.join(promisePending)).status).not.toBe(400)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"public SSE and generic native plugin subscriptions receive RPC events across locations",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const Updates = Rpc.define({
|
||||
id: "updates",
|
||||
methods: { emit: { input: Schema.String, output: Schema.Undefined } },
|
||||
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
|
||||
})
|
||||
const received: RpcEvent[] = []
|
||||
const observed = yield* Deferred.make<void>()
|
||||
const server = yield* fixture([
|
||||
define({
|
||||
id: "updates-implementer",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const registration = yield* ctx.rpc.register(Updates, {
|
||||
emit: (input): Effect.Effect<undefined> =>
|
||||
registration.events.emit("updated", { text: input }).pipe(Effect.as(undefined), Effect.orDie),
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
}),
|
||||
define({
|
||||
id: "native-observer",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
// One observer instance should see both locations, just like the public native stream.
|
||||
if (path.basename(ctx.location.directory) !== "first") return
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
|
||||
Stream.take(2),
|
||||
Stream.runForEach((event) => Effect.sync(() => received.push(event))),
|
||||
Effect.andThen(Deferred.succeed(observed, undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}).pipe(Effect.orDie),
|
||||
}),
|
||||
])
|
||||
yield* server.boot(server.first)
|
||||
yield* server.boot(server.second)
|
||||
const response = yield* Effect.promise(() =>
|
||||
server.handler(
|
||||
new Request("http://opencode.local/api/event", {
|
||||
headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
if (!response.body) throw new Error("Expected an SSE body")
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel()))
|
||||
expect((yield* Effect.promise(() => reader.read())).value).toContain('"type":"server.connected"')
|
||||
const first = yield* server.call("updates/emit", { input: "first" }, { directory: server.first })
|
||||
const second = yield* server.call("updates/emit", { input: "second" }, { directory: server.second })
|
||||
expect(first.status).toBe(200)
|
||||
expect(second.status).toBe(200)
|
||||
const events: RpcEvent[] = []
|
||||
while (events.length < 2) {
|
||||
const chunk = yield* Effect.promise(() => reader.read())
|
||||
if (chunk.done) throw new Error("Event stream closed before RPC events arrived")
|
||||
events.push(
|
||||
...chunk.value
|
||||
.split("\n\n")
|
||||
.filter((frame) => frame.startsWith("data: "))
|
||||
.map((frame) => Schema.decodeUnknownSync(Schema.fromJsonString(OpenCodeEvent))(frame.slice(6)))
|
||||
.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
|
||||
)
|
||||
}
|
||||
yield* Deferred.await(observed)
|
||||
expect(events).toMatchObject([
|
||||
{
|
||||
type: "rpc.updates.updated",
|
||||
location: { directory: server.first },
|
||||
data: { text: "first" },
|
||||
},
|
||||
{
|
||||
type: "rpc.updates.updated",
|
||||
location: { directory: server.second },
|
||||
data: { text: "second" },
|
||||
},
|
||||
])
|
||||
expect(received).toEqual(events)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
@@ -195,7 +195,8 @@ it.live(
|
||||
expect(yield* Effect.promise<unknown>(() => response.json())).toEqual({ data: [] })
|
||||
}
|
||||
}
|
||||
expect(boots).toEqual([])
|
||||
// Reading permissions or forms builds the Session's Instance and starts its plugin activation at once; the
|
||||
// ordering assertion after the prompts is what proves each Session boots exactly once.
|
||||
|
||||
for (const config of configs) {
|
||||
const session = yield* sessions.get(config.id)
|
||||
|
||||
@@ -55,7 +55,12 @@ it.live("updates completed assistant message content through the session HTTP AP
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } },
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
|
||||
@@ -81,7 +81,12 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-failure-")))
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } },
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SdkPlugins.node.replace(
|
||||
|
||||
@@ -88,7 +88,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
|
||||
import { localPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
@@ -188,7 +188,7 @@ export type TuiInput = {
|
||||
updater?: {
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageResolver
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user