mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 22:46:20 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571699654a | ||
|
|
9a101931d9 | ||
|
|
9961f207f2 | ||
|
|
9f01e2b548 | ||
|
|
7856515140 | ||
|
|
818804e181 | ||
|
|
ce6247bd2f | ||
|
|
0465328297 | ||
|
|
43d09b9d75 | ||
|
|
7730cec123 | ||
|
|
367ee47d7e | ||
|
|
c596a8f11d | ||
|
|
30f998c5b9 | ||
|
|
d4b4dd17cc | ||
|
|
f330f3e02b | ||
|
|
a9c27209bb | ||
|
|
831f8f6cd1 | ||
|
|
cff1d0fe01 | ||
|
|
e297da82ae | ||
|
|
77eac47493 | ||
|
|
aadc0c1b4b | ||
|
|
94caa36fd4 | ||
|
|
a6f75d483a | ||
|
|
000b42d204 | ||
|
|
02440f6715 | ||
|
|
d46ed9e9db | ||
|
|
71779ae5de | ||
|
|
4a3e25beab | ||
|
|
23fde448ec | ||
|
|
1137861188 | ||
|
|
815d4ab9b4 | ||
|
|
5d73a5789f | ||
|
|
df05945042 | ||
|
|
6a99898ef7 | ||
|
|
a40a87276a | ||
|
|
a20cbc394e | ||
|
|
8fda87614f | ||
|
|
dffd95ce7c | ||
|
|
b0402f5a34 | ||
|
|
54b00ec5fe |
@@ -969,6 +969,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -978,6 +979,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -3211,8 +3213,6 @@
|
||||
|
||||
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
@@ -5451,8 +5451,6 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
@@ -5667,8 +5665,6 @@
|
||||
|
||||
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
|
||||
|
||||
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -5817,9 +5813,7 @@
|
||||
|
||||
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -5851,8 +5845,6 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
@@ -6607,8 +6599,6 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
|
||||
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
|
||||
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
|
||||
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
|
||||
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
|
||||
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
|
||||
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
|
||||
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"packageManager": "bun@1.4.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
|
||||
@@ -1257,9 +1257,12 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
if (
|
||||
item.type !== "function_call" ||
|
||||
!item.call_id ||
|
||||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
|
||||
)
|
||||
continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
|
||||
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -4032,7 +4032,6 @@ describe("OpenAI Responses route", () => {
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
|
||||
@@ -123,7 +123,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -131,10 +131,8 @@ async function setup(page: Page) {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -144,6 +142,6 @@ async function setup(page: Page) {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,8 +107,6 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "C:/Projects/extensions-demo"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const session = {
|
||||
id: "ses_project_extensions",
|
||||
title: "Existing session",
|
||||
directory,
|
||||
projectID: "proj_extensions_demo",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
|
||||
|
||||
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: session.projectID,
|
||||
canonical: directory,
|
||||
name: "Extensions demo",
|
||||
vcs: "git",
|
||||
time: session.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: session.id, directory },
|
||||
)
|
||||
const href = `/server/${base64Encode(server)}/session/${session.id}`
|
||||
await page.goto(href)
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByText("Extensions demo", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
|
||||
|
||||
const globalPlugins = Promise.withResolvers<void>()
|
||||
const projectPlugins = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
const project = new URL(route.request().url()).searchParams.get("location[directory]")
|
||||
await (project ? projectPlugins : globalPlugins).promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
location: project ? { directory: project } : {},
|
||||
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
|
||||
id,
|
||||
source: { type: "package", package: id },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
})),
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
const requested = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
|
||||
})
|
||||
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await requested
|
||||
await expect(page).toHaveURL(href)
|
||||
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
|
||||
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
globalPlugins.resolve()
|
||||
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
|
||||
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
|
||||
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
projectPlugins.resolve()
|
||||
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
|
||||
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(href)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
|
||||
})
|
||||
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID }) => {
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID },
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
await selectFile(page, "alpha.ts")
|
||||
|
||||
await switchSession(page, titleB)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await selectFile(page, "gamma.ts")
|
||||
|
||||
|
||||
@@ -18,37 +18,72 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The v2 review pane's diff data is workspace-scoped: switching between session
|
||||
// tabs in the same workspace must update its parameters reactively instead of
|
||||
// tearing the pane down and remounting it (which flickers).
|
||||
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
||||
// The review pane's data is workspace-scoped, but visibility belongs to each
|
||||
// session tab. Switching tabs must keep the pane mounted without opening it.
|
||||
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toContain("0.24s")
|
||||
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await expect(review).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => getComputedStyle(element).transitionDuration),
|
||||
)
|
||||
.toBe("0s")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "520px")
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
({ key }) => {
|
||||
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
|
||||
return panes[key]?.review
|
||||
},
|
||||
{ key: `${server}\n${sessionHref(sessionA)}` },
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expect(chatPanel).toHaveCSS("width", "580px")
|
||||
|
||||
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
@@ -102,7 +137,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
({ directory, server, sessions, panes }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -114,8 +149,19 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ tabKey, server, sessionID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
|
||||
@@ -68,41 +68,20 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
|
||||
test("extensions opens without waiting for MCPs", async ({ page }) => {
|
||||
const mcps = Promise.withResolvers<void>()
|
||||
const plugins = Promise.withResolvers<void>()
|
||||
await page.route("**/api/mcp", async (route) => {
|
||||
await mcps.promise
|
||||
await route.fulfill({
|
||||
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
|
||||
})
|
||||
})
|
||||
await page.route("**/api/plugin", async (route) => {
|
||||
await plugins.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: [
|
||||
{
|
||||
id: "demo-plugin",
|
||||
source: { type: "package", package: "demo-plugin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
plugins.resolve()
|
||||
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
|
||||
mcps.resolve()
|
||||
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
|
||||
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
|
||||
|
||||
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs.panes",
|
||||
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
{
|
||||
terminalKey: terminalStorageKey(),
|
||||
ptyID,
|
||||
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
server,
|
||||
sessionID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||
// of tearing it down and reconnecting.
|
||||
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||
// Terminal processes are workspace-scoped, but panel visibility belongs to each
|
||||
// session tab. Switching tabs must keep the PTY alive without opening its panel.
|
||||
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
const terminalPanel = page.locator('[data-component="terminal-panel"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
const connection = new URL(connections[0]!)
|
||||
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "180px")
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminalPanel).toHaveCSS("height", "300px")
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
@@ -120,7 +133,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
({ directory, server, sessions, panes }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -132,8 +145,20 @@ async function setup(page: Page) {
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
|
||||
}
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
|
||||
},
|
||||
{
|
||||
directory,
|
||||
server,
|
||||
sessions: [sessionA, sessionB],
|
||||
panes: {
|
||||
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
|
||||
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
|
||||
},
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
return connections
|
||||
}
|
||||
|
||||
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
await expectToken(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
|
||||
)
|
||||
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
}
|
||||
|
||||
async function expectToken(element: Locator, property: string, token: string) {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.color = `var(${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, color)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { pluginLabels } from "./plugin"
|
||||
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "package-plugin",
|
||||
source: { type: "package", package: "example" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: "local-plugin",
|
||||
source: { type: "local", path: "/tmp/plugin.ts" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
|
||||
]
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "package") return plugin.source.target
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
|
||||
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
|
||||
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
return {
|
||||
show,
|
||||
animate: previous !== undefined && (previous.animate || previous.show !== show),
|
||||
value: current ?? previous?.value,
|
||||
}
|
||||
})
|
||||
export function createAnimatedPresence<T>(
|
||||
value: Accessor<T | undefined>,
|
||||
element: Accessor<HTMLElement | null>,
|
||||
identity?: Accessor<unknown>,
|
||||
) {
|
||||
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
|
||||
(previous) => {
|
||||
const currentIdentity = identity?.()
|
||||
const current = value()
|
||||
const show = current !== undefined
|
||||
const same = !identity || previous?.identity === currentIdentity
|
||||
return {
|
||||
identity: currentIdentity,
|
||||
show,
|
||||
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
|
||||
value: current ?? (same ? previous?.value : undefined),
|
||||
}
|
||||
},
|
||||
)
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
|
||||
@@ -155,6 +155,16 @@ describe("isSessionNotFoundError", () => {
|
||||
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("matches a structured error stored directly as the cause", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
sessionID: "ses_missing",
|
||||
message: "Session not found",
|
||||
} satisfies SessionNotFoundError
|
||||
|
||||
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects errors for other sessions and other 404 responses", () => {
|
||||
const body = {
|
||||
_tag: "SessionNotFoundError",
|
||||
|
||||
@@ -36,10 +36,9 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
|
||||
}
|
||||
|
||||
function unwrapNamedError(error: unknown): unknown {
|
||||
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
|
||||
return (error.cause as Record<string, unknown>).body
|
||||
}
|
||||
return error
|
||||
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
|
||||
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
|
||||
return error.cause
|
||||
}
|
||||
|
||||
// Client-synthesized session not-found errors share one constructor and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
@@ -45,6 +45,33 @@ describe("buildFileTreeV2Model", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortFileTreeV2Paths", () => {
|
||||
test("orders navigation depth-first with directories before sibling files", () => {
|
||||
const paths = ["README.md", "src/a.ts", "src/lib/z.ts", "docs/guide.md", "src/lib/b.ts"]
|
||||
|
||||
expect(sortFileTreeV2Paths(paths)).toEqual([
|
||||
"docs/guide.md",
|
||||
"src/lib/b.ts",
|
||||
"src/lib/z.ts",
|
||||
"src/a.ts",
|
||||
"README.md",
|
||||
])
|
||||
expect(paths[0]).toBe("README.md")
|
||||
})
|
||||
|
||||
test("preserves original paths for selection", () => {
|
||||
expect(sortFileTreeV2Paths(["README.md", "src\\lib\\a.ts", "/docs//guide.md/"])).toEqual([
|
||||
"/docs//guide.md/",
|
||||
"src\\lib\\a.ts",
|
||||
"README.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("handles an empty file list", () => {
|
||||
expect(sortFileTreeV2Paths([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("flattenLiveFileTreeV2", () => {
|
||||
test("flattens live children using original paths for nested lookups", () => {
|
||||
const nodes: Record<string, FileNode[]> = {
|
||||
|
||||
@@ -76,6 +76,12 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
|
||||
return rows
|
||||
}
|
||||
|
||||
export function sortFileTreeV2Paths(paths: readonly string[]) {
|
||||
return flattenFileTreeV2(buildFileTreeV2Model(paths), () => true)
|
||||
.filter((row) => row.node.type === "file")
|
||||
.map((row) => row.node.originalPath)
|
||||
}
|
||||
|
||||
export function flattenLiveFileTreeV2(
|
||||
children: (path: string) => readonly FileNode[],
|
||||
expanded: (path: string) => boolean,
|
||||
|
||||
@@ -129,6 +129,7 @@ export function useSessionModel() {
|
||||
layout: {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SessionReviewLineComment,
|
||||
} from "@opencode-ai/session-ui/session-review"
|
||||
import FileTreeV2 from "@/session/files/file-tree-v2"
|
||||
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -83,6 +84,9 @@ export function ReviewPanelView(
|
||||
),
|
||||
)
|
||||
const searching = createMemo(() => props.state.filter().trim().length > 0)
|
||||
const navigationFiles = createMemo(() =>
|
||||
searching() || props.fileList === "flat" ? filteredFiles() : sortFileTreeV2Paths(filteredFiles()),
|
||||
)
|
||||
const kinds = createMemo(() => reviewDiffKinds(diffs()))
|
||||
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
|
||||
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
|
||||
@@ -93,7 +97,7 @@ export function ReviewPanelView(
|
||||
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
|
||||
const active = props.activeFile
|
||||
if (searching()) return active
|
||||
const files = filteredFiles()
|
||||
const files = navigationFiles()
|
||||
if (active && files.includes(active)) return active
|
||||
return files[0]
|
||||
})
|
||||
@@ -141,7 +145,7 @@ export function ReviewPanelView(
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
files={filteredFiles()}
|
||||
files={navigationFiles()}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const size = createSizing()
|
||||
const view = session.layout.view
|
||||
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
|
||||
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
|
||||
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
|
||||
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
|
||||
const resizedWidth = createMemo(() =>
|
||||
clampSessionPanelWidth({
|
||||
width: layout.session.width(),
|
||||
width: view().reviewPanel.width(),
|
||||
available: available(),
|
||||
split: splitReview(),
|
||||
}),
|
||||
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
|
||||
const sideHeight = createMemo(() => rowSize.height)
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
const layout = useLayout()
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
})
|
||||
const bottomTerminalPresence = createPresence({
|
||||
show: bottomTerminalVisible,
|
||||
element: () => elements.bottomTerminal ?? null,
|
||||
const sidePresence = createAnimatedPresence(
|
||||
() => sideVisible() || undefined,
|
||||
() => elements.side ?? null,
|
||||
session.layout.tabKey,
|
||||
)
|
||||
const bottomTerminalPresence = createAnimatedPresence(
|
||||
() => bottomTerminalVisible() || undefined,
|
||||
() => elements.bottomTerminal ?? null,
|
||||
session.layout.tabKey,
|
||||
)
|
||||
const sideMotion = createMemo<{
|
||||
key?: string
|
||||
region: boolean
|
||||
terminal: boolean
|
||||
animateRegion: boolean
|
||||
animateTerminal: boolean
|
||||
}>((previous) => {
|
||||
const key = session.layout.tabKey()
|
||||
const region = screen.side.region.open()
|
||||
const terminal = sideTerminalVisible()
|
||||
const sameTab = previous?.key === key
|
||||
return {
|
||||
key,
|
||||
region,
|
||||
terminal,
|
||||
animateRegion: !!previous && sameTab && previous.region !== region,
|
||||
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
|
||||
}
|
||||
})
|
||||
const paneAnimating = () =>
|
||||
sidePresence.animate() ||
|
||||
sideMotion().animateRegion ||
|
||||
sideMotion().animateTerminal ||
|
||||
bottomTerminalPresence.animate()
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
classList={{
|
||||
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active(),
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
style={{
|
||||
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
max={screen.panel.max()}
|
||||
onResize={(width) => {
|
||||
screen.size.touch()
|
||||
layout.session.resize(width)
|
||||
session.layout.view().reviewPanel.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={sidePresence.present() || store.sideTerminalPresent}>
|
||||
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
|
||||
<div
|
||||
ref={(element) => setElements("side", element)}
|
||||
data-slot="session-side-panel-presence"
|
||||
data-opened={sideVisible()}
|
||||
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
data-slot="session-side-region"
|
||||
classList={{
|
||||
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
data-opened={screen.side.region.open()}
|
||||
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
|
||||
class="absolute inset-0"
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"delay-0": !screen.side.gap.closing(),
|
||||
"delay-[200ms]": screen.side.gap.closing(),
|
||||
"transition-none": !paneAnimating(),
|
||||
}}
|
||||
style={{ height: screen.side.gap.height() }}
|
||||
onPointerDown={() => screen.size.start()}
|
||||
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
size={session.layout.view().terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
session.layout.view().terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
data-slot="session-side-terminal-region"
|
||||
classList={{
|
||||
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
}}
|
||||
style={{ height: screen.side.terminal.height() }}
|
||||
>
|
||||
<Show when={store.sideTerminalPresent}>
|
||||
<div
|
||||
data-slot="side-terminal-panel-presence"
|
||||
data-opened={sideTerminalVisible()}
|
||||
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
|
||||
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
|
||||
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
fill
|
||||
framed={false}
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
data-opened={bottomTerminalVisible()}
|
||||
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
|
||||
classList={{
|
||||
hidden: !bottomTerminalPresence.present(),
|
||||
"relative min-h-0 shrink-0": isDesktop(),
|
||||
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
size={session.layout.view().terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
layout.terminal.resize(height)
|
||||
session.layout.view().terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
|
||||
<TerminalPanel
|
||||
stacked={isDesktop()}
|
||||
present={store.bottomTerminalCached}
|
||||
animate={bottomTerminalPresence.animate()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
export const useSessionKey = () => {
|
||||
const params = useParams()
|
||||
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
|
||||
|
||||
export const useSessionLayout = () => {
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const { params, sessionKey, workspaceKey } = useSessionKey()
|
||||
const serverSDK = useServerSDK()
|
||||
const currentTab = createMemo(() => {
|
||||
if (!params.id) return
|
||||
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
|
||||
})
|
||||
const panes = {
|
||||
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
|
||||
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
|
||||
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
|
||||
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
|
||||
reviewOpened: () => tabs.pane(currentTab(), "review"),
|
||||
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
|
||||
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
|
||||
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
|
||||
}
|
||||
return {
|
||||
params,
|
||||
sessionKey,
|
||||
workspaceKey,
|
||||
tabKey: createMemo(() => {
|
||||
const tab = currentTab()
|
||||
return tab && tabKey(tab)
|
||||
}),
|
||||
tabs: createMemo(() => layout.tabs(sessionKey)),
|
||||
view: createMemo(() => layout.view(sessionKey)),
|
||||
view: createMemo(() => layout.view(sessionKey, panes)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
|
||||
import { Terminal } from "@/session/terminal/terminal"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { terminalTabLabel } from "@/session/terminal/terminal-label"
|
||||
@@ -45,9 +44,9 @@ export function TerminalPanel(
|
||||
present?: boolean
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
@@ -57,7 +56,7 @@ export function TerminalPanel(
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const height = createMemo(() => view().terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
@@ -238,10 +237,11 @@ export function TerminalPanel(
|
||||
pane={pane()}
|
||||
max={max()}
|
||||
resizing={size.active()}
|
||||
animate={props.animate}
|
||||
onResizeStart={size.start}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
view().terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ export function TerminalSurface(
|
||||
pane: number
|
||||
max: number
|
||||
resizing: boolean
|
||||
animate?: boolean
|
||||
onResizeStart: () => void
|
||||
onResize: (height: number) => void
|
||||
onCollapse: () => void
|
||||
@@ -27,7 +28,9 @@ export function TerminalSurface(
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
|
||||
data-size-animated={
|
||||
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
|
||||
}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
|
||||
@@ -478,7 +478,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="relative w-full h-full min-w-0"
|
||||
data-workspace-session={props.workspaceSession() ? "" : undefined}
|
||||
data-local-session={!props.workspaceSession() ? "" : undefined}
|
||||
>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
|
||||
@@ -59,6 +59,16 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s
|
||||
export type ReviewDiffStyle = "unified" | "split"
|
||||
export type ReviewChangeMode = "git" | "branch" | "turn"
|
||||
export type ReviewPanelSource = "context-button" | "other"
|
||||
export type TabPanes = {
|
||||
terminalOpened: Accessor<boolean>
|
||||
setTerminalOpened(opened: boolean): void
|
||||
terminalHeight: Accessor<number | undefined>
|
||||
setTerminalHeight(height: number): void
|
||||
reviewOpened: Accessor<boolean>
|
||||
setReviewOpened(opened: boolean): void
|
||||
sessionWidth: Accessor<number | undefined>
|
||||
setSessionWidth(width: number): void
|
||||
}
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
@@ -508,7 +518,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return message
|
||||
},
|
||||
},
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const reviewMode = createMemo(() => {
|
||||
@@ -519,11 +529,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const file = s().reviewFile
|
||||
if (typeof file === "string") return file
|
||||
})
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
|
||||
const terminalHeight = createMemo(() =>
|
||||
panes
|
||||
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
|
||||
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
|
||||
)
|
||||
const reviewPanelOpened =
|
||||
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
const sessionWidth = createMemo(() =>
|
||||
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
|
||||
)
|
||||
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
if (panes) {
|
||||
panes.setTerminalOpened(next)
|
||||
return
|
||||
}
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
|
||||
@@ -537,6 +560,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
|
||||
const nextSource = next ? source : "other"
|
||||
if (panes) {
|
||||
batch(() => {
|
||||
panes.setReviewOpened(next)
|
||||
setEphemeral("reviewPanelSource", nextSource)
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
batch(() => {
|
||||
@@ -566,6 +596,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
height: terminalHeight,
|
||||
resize(height: number) {
|
||||
if (panes) {
|
||||
panes.setTerminalHeight(height)
|
||||
return
|
||||
}
|
||||
setStore("terminal", "height", height)
|
||||
},
|
||||
open() {
|
||||
setTerminalOpened(true)
|
||||
},
|
||||
@@ -579,6 +617,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
reviewPanel: {
|
||||
opened: reviewPanelOpened,
|
||||
source: reviewPanelSource,
|
||||
width: sessionWidth,
|
||||
resize(width: number) {
|
||||
if (panes) {
|
||||
panes.setSessionWidth(width)
|
||||
return
|
||||
}
|
||||
setStore("session", "width", width)
|
||||
},
|
||||
open(source: ReviewPanelSource = "other") {
|
||||
setReviewPanelOpened(true, source)
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { migrateTabs } from "./migration"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
@@ -48,8 +48,12 @@ test("session tab identity stays rooted while its href follows the child route",
|
||||
})
|
||||
|
||||
test("finds open root and routed session tabs", () => {
|
||||
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
|
||||
const tab = { ...sessionTab("root"), routeSessionId: "child" }
|
||||
const tabs = [tab]
|
||||
|
||||
expect(findSessionTab(tabs, server, "root")).toBe(tab)
|
||||
expect(findSessionTab(tabs, server, "child")).toBe(tab)
|
||||
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
|
||||
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
|
||||
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
|
||||
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
|
||||
|
||||
@@ -46,6 +46,10 @@ export type TabInfo = {
|
||||
directory?: string
|
||||
}
|
||||
|
||||
export type TabPane = "terminal" | "review"
|
||||
export type TabPaneSize = "terminalHeight" | "sessionWidth"
|
||||
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
|
||||
|
||||
type RecentTab = {
|
||||
key?: string
|
||||
}
|
||||
@@ -62,8 +66,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
|
||||
return sessionIDHasOpenTab(tabs, server, session.id)
|
||||
}
|
||||
|
||||
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
|
||||
return tabs.some(
|
||||
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
|
||||
return tabs.find(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
tab.server === server &&
|
||||
@@ -71,6 +75,10 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
|
||||
return !!findSessionTab(tabs, server, sessionID)
|
||||
}
|
||||
|
||||
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
name: "Tabs",
|
||||
gate: false,
|
||||
@@ -89,6 +97,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
Persist.window("tabs.info"),
|
||||
createStore<Record<string, TabInfo>>({}),
|
||||
)
|
||||
const [panes, setPanes, , panesReady] = persisted(
|
||||
Persist.window("tabs.panes"),
|
||||
createStore<Record<string, TabPaneState>>({}),
|
||||
)
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
|
||||
|
||||
@@ -141,6 +153,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
)
|
||||
}
|
||||
|
||||
const removePanes = (key: string) => {
|
||||
if (!panes[key]) return
|
||||
setPanes(
|
||||
produce((draft) => {
|
||||
delete draft[key]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
onCleanup(memory.dispose)
|
||||
|
||||
createEffect(() => {
|
||||
@@ -153,6 +174,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
memory.remove(key)
|
||||
removeInfo(key)
|
||||
removePanes(key)
|
||||
}
|
||||
}
|
||||
setStore(() => next)
|
||||
@@ -162,6 +184,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
for (const key of Object.keys(info)) {
|
||||
if (!keys.has(key)) removeInfo(key)
|
||||
}
|
||||
if (!panesReady()) return
|
||||
for (const key of Object.keys(panes)) {
|
||||
if (!keys.has(key)) removePanes(key)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -198,6 +224,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
removeInfo(key)
|
||||
removePanes(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
}
|
||||
|
||||
@@ -491,8 +518,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
stateValue<T>(tab: Tab, name: string) {
|
||||
return memory.get<T>(tabKey(tab), name)
|
||||
},
|
||||
pane(tab: Tab | undefined, pane: TabPane) {
|
||||
if (!tab) return false
|
||||
return panes[tabKey(tab)]?.[pane] ?? false
|
||||
},
|
||||
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const current = panes[key]
|
||||
if (current?.[pane] === opened) return
|
||||
if (!current) {
|
||||
setPanes(key, { [pane]: opened })
|
||||
return
|
||||
}
|
||||
setPanes(key, pane, opened)
|
||||
},
|
||||
paneSize(tab: Tab | undefined, size: TabPaneSize) {
|
||||
if (!tab) return
|
||||
return panes[tabKey(tab)]?.[size]
|
||||
},
|
||||
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const current = panes[key]
|
||||
if (current?.[size] === value) return
|
||||
if (!current) {
|
||||
setPanes(key, { [size]: value })
|
||||
return
|
||||
}
|
||||
setPanes(key, size, value)
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, infoReady, recentReady }
|
||||
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createAnimatedPresence } from "../src/runtime/animated-presence"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { batch, createRoot, createSignal } from "solid-js"
|
||||
|
||||
test("animates visibility changes without animating initial presence", () => {
|
||||
createRoot((dispose) => {
|
||||
@@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("does not animate visibility changes across identities", () => {
|
||||
createRoot((dispose) => {
|
||||
const [identity, setIdentity] = createSignal("a")
|
||||
const [value, setValue] = createSignal<string | undefined>("visible")
|
||||
const presence = createAnimatedPresence(value, () => null, identity)
|
||||
|
||||
expect(presence.animate()).toBe(false)
|
||||
batch(() => {
|
||||
setIdentity("b")
|
||||
setValue(undefined)
|
||||
})
|
||||
expect(presence.animate()).toBe(false)
|
||||
|
||||
setValue("visible")
|
||||
expect(presence.animate()).toBe(true)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,6 +199,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("check", {
|
||||
description: "Check package plugins for updates",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("update", {
|
||||
description: "Update package plugins",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { format, inspect } from "./inventory"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.check,
|
||||
Effect.fn("cli.plugin.check")(function* (input) {
|
||||
const result = yield* inspect(Option.getOrUndefined(input.target))
|
||||
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
|
||||
if (result.items.some((item) => item.error)) process.exitCode = 1
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export interface Item {
|
||||
readonly runtime: "Server" | "TUI"
|
||||
readonly target: string
|
||||
readonly name: string
|
||||
readonly version?: string
|
||||
readonly outdated: boolean
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = { directory: process.cwd() }
|
||||
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
|
||||
const serverTargets = new Set(
|
||||
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
|
||||
)
|
||||
const server =
|
||||
selected === undefined || serverTargets.has(selected)
|
||||
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
|
||||
: listed
|
||||
const serverItems = server.data.flatMap((plugin): Item[] => {
|
||||
if (plugin.source.type !== "package") return []
|
||||
if (selected !== undefined && plugin.source.target !== selected) return []
|
||||
return [
|
||||
{
|
||||
runtime: "Server",
|
||||
target: plugin.source.target,
|
||||
name: plugin.id ?? plugin.source.target,
|
||||
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
|
||||
outdated: plugin.source.outdated === true,
|
||||
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const config = yield* Config.Service
|
||||
const info = yield* config.get()
|
||||
const configured = [
|
||||
...new Set(
|
||||
(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
|
||||
return [target]
|
||||
}),
|
||||
),
|
||||
]
|
||||
const tuiTargets = yield* Effect.promise(async () => {
|
||||
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
|
||||
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
|
||||
})
|
||||
const npm = yield* Npm.Service
|
||||
const tuiItems = yield* Effect.forEach(
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
target,
|
||||
name: target,
|
||||
...(installed.version ? { version: displayVersion(installed.version) } : {}),
|
||||
outdated: Exit.isSuccess(outdated) && outdated.value,
|
||||
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const items = [...serverItems, ...tuiItems]
|
||||
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
|
||||
return { client, location, items }
|
||||
})
|
||||
|
||||
export function displayVersion(version: string) {
|
||||
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
|
||||
}
|
||||
|
||||
export function format(items: readonly Item[]) {
|
||||
return ["Server", "TUI"]
|
||||
.flatMap((runtime) => {
|
||||
const rows = items.filter((item) => item.runtime === runtime)
|
||||
if (!rows.length) return []
|
||||
return [
|
||||
runtime,
|
||||
...rows.map(
|
||||
(item) =>
|
||||
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
|
||||
),
|
||||
]
|
||||
})
|
||||
.join(EOL)
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -22,15 +24,23 @@ export default Runtime.handler(
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
|
||||
return []
|
||||
const local = localSource(target, path.dirname(config.path))
|
||||
if (local) return [{ target: fileURLToPath(local), version: "local" }]
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[
|
||||
...(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
|
||||
}),
|
||||
...discovered.map((target) => ({ target, source: "discovered" as const })),
|
||||
],
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
@@ -43,37 +53,50 @@ export default Runtime.handler(
|
||||
|
||||
export function format(
|
||||
plugins: readonly PluginInfo[],
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
|
||||
builtin = false,
|
||||
) {
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.state.status !== "active" || !plugin.features.tui
|
||||
? []
|
||||
: plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: plugin.source.type === "local"
|
||||
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.map((plugin) => ({
|
||||
id: plugin.id ?? "-",
|
||||
version:
|
||||
plugin.source.type === "package"
|
||||
? (plugin.source.version ?? "-")
|
||||
: plugin.source.type === "local"
|
||||
? "local"
|
||||
: "-",
|
||||
target:
|
||||
plugin.source.type === "package"
|
||||
? plugin.source.target
|
||||
: plugin.source.type === "local"
|
||||
? plugin.source.path
|
||||
: plugin.source.type,
|
||||
}))
|
||||
const targets = tui
|
||||
.filter(
|
||||
(item) =>
|
||||
!plugins.some((plugin) =>
|
||||
plugin.source.type === "package"
|
||||
? plugin.source.target === item.target
|
||||
: plugin.source.type === "local" &&
|
||||
(plugin.source.path === item.target ||
|
||||
(plugin.features.tui &&
|
||||
path.dirname(plugin.source.path) ===
|
||||
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
.toSorted((a, b) => a.target.localeCompare(b.target))
|
||||
.map((plugin) => `${plugin.target} (${plugin.source})`)
|
||||
return [
|
||||
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
|
||||
server.length ? ["Server", ...server].join(EOL) : undefined,
|
||||
]
|
||||
.filter((section) => section !== undefined)
|
||||
.join(EOL + EOL)
|
||||
}
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
|
||||
const rows = [...server, ...targets]
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
|
||||
.map((item) => [
|
||||
item.id,
|
||||
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
|
||||
item.target,
|
||||
])
|
||||
if (!rows.length) return ""
|
||||
const table = [["ID", "VERSION", "SOURCE"], ...rows]
|
||||
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
|
||||
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Cause, Effect, Exit, Option } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { inspect } from "./inventory"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.update,
|
||||
Effect.fn("cli.plugin.update")(function* (input) {
|
||||
const result = yield* inspect(Option.getOrUndefined(input.target))
|
||||
for (const item of result.items) {
|
||||
if (!item.error) continue
|
||||
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
|
||||
}
|
||||
const selected = result.items.filter((item) => item.outdated)
|
||||
if (!selected.length) {
|
||||
process.stdout.write("No plugin updates available" + EOL)
|
||||
if (result.items.some((item) => item.error)) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const npm = yield* Npm.Service
|
||||
const updated = yield* Effect.forEach(
|
||||
selected,
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
for (const item of updated) {
|
||||
if (Exit.isSuccess(item.result)) {
|
||||
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
|
||||
continue
|
||||
}
|
||||
process.stderr.write(
|
||||
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
@@ -91,7 +93,7 @@ type RenderOptions = {
|
||||
const colors = terminalPalette()
|
||||
|
||||
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
|
||||
const totalTokens = tokenTotal(stats.tokens)
|
||||
const totalTokens = TokenUsage.total(stats.tokens)
|
||||
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
|
||||
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
|
||||
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
|
||||
@@ -167,43 +169,23 @@ function renderActivity(
|
||||
color: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const values = new Map(activity.map((day) => [day.date, day.steps]))
|
||||
const rangeStart = dateOrdinal(new Date(from))
|
||||
const rangeEnd = dateOrdinal(new Date(to - 1))
|
||||
const end = new Date(to - 1)
|
||||
end.setHours(12, 0, 0, 0)
|
||||
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
|
||||
const start = new Date(from)
|
||||
start.setHours(12, 0, 0, 0)
|
||||
start.setDate(start.getDate() - mondayIndex(start))
|
||||
const maxWeeks = Math.max(1, Math.min(53, width - 4))
|
||||
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
|
||||
const latest = new Date(end)
|
||||
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
|
||||
if (start < latest) start.setTime(latest.getTime())
|
||||
|
||||
const active = [...values.values()].filter((value) => value > 0)
|
||||
const levels = [...new Set(active)].sort((a, b) => a - b)
|
||||
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
|
||||
const date = new Date(start)
|
||||
date.setDate(date.getDate() + week * 7)
|
||||
return date
|
||||
})
|
||||
const weeks = weekStarts.map((week) =>
|
||||
Array.from({ length: 7 }, (_, day) => {
|
||||
const date = new Date(week)
|
||||
date.setDate(date.getDate() + day)
|
||||
const ordinal = dateOrdinal(date)
|
||||
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
|
||||
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
|
||||
}),
|
||||
)
|
||||
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
|
||||
const months = calendar.months
|
||||
.map((month, index) =>
|
||||
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
|
||||
)
|
||||
.join("")
|
||||
.trimEnd()
|
||||
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||||
return [
|
||||
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
|
||||
` ${style(monthLabels(weekStarts), "2", color)}`,
|
||||
style(
|
||||
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
|
||||
`1;${colors.primary}`,
|
||||
color,
|
||||
),
|
||||
` ${style(months, "2", color)}`,
|
||||
...weekdays.flatMap((label, day) => [
|
||||
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
|
||||
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
|
||||
...(day === weekdays.length - 1 ? [] : [""]),
|
||||
]),
|
||||
"",
|
||||
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
width,
|
||||
),
|
||||
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
]),
|
||||
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
...models.map((item) =>
|
||||
tableRow(
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
formatNumber(tokenTotal(item.tokens)),
|
||||
formatNumber(TokenUsage.total(item.tokens)),
|
||||
formatNumber(item.steps),
|
||||
`$${item.cost.toFixed(2)}`,
|
||||
),
|
||||
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
|
||||
return value.length <= width ? value : value.slice(0, width - 1) + "…"
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
|
||||
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
|
||||
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
|
||||
return color ? `\x1b[${code}m${value}\x1b[0m` : value
|
||||
}
|
||||
|
||||
function activityGlyph(value: number, levels: number[], color: boolean) {
|
||||
if (value === 0) return paintActivity(0, color)
|
||||
const index = levels.indexOf(value)
|
||||
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
|
||||
return paintActivity(level, color)
|
||||
}
|
||||
|
||||
function paintActivity(level: number, color: boolean) {
|
||||
const glyph = ["·", "░", "▒", "▓", "█"][level]
|
||||
if (!color) return glyph
|
||||
@@ -370,39 +341,3 @@ function terminalPalette() {
|
||||
}
|
||||
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
|
||||
}
|
||||
|
||||
function monthLabels(weeks: Date[]) {
|
||||
const line: string[] = []
|
||||
weeks.reduce((previous, week, index) => {
|
||||
const middle = new Date(week)
|
||||
middle.setDate(middle.getDate() + 3)
|
||||
const month = middle.getMonth()
|
||||
if (month === previous) return previous
|
||||
Intl.DateTimeFormat("en-US", { month: "short" })
|
||||
.format(middle)
|
||||
.split("")
|
||||
.forEach((character, offset) => {
|
||||
line[index + offset] = character
|
||||
})
|
||||
return month
|
||||
}, -1)
|
||||
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
|
||||
.join("")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
function dateOrdinal(date: Date) {
|
||||
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
add: () => import("./commands/handlers/plugin/add"),
|
||||
check: () => import("./commands/handlers/plugin/check"),
|
||||
update: () => import("./commands/handlers/plugin/update"),
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
|
||||
@@ -85,8 +85,6 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
session_delete: false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
scroll_speed: 2,
|
||||
scroll_acceleration: { enabled: true },
|
||||
@@ -135,7 +133,6 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
test("routes packages according to their exported runtimes", () => {
|
||||
expect(configurationTarget("server.js", "tui.js")).toBe("server")
|
||||
expect(configurationTarget("server.js", undefined)).toBe("server")
|
||||
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
|
||||
expect(configurationTarget(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("adds a package to global plugin config without replacing unrelated settings", async () => {
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "opencode.jsonc")
|
||||
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
|
||||
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { EOL } from "node:os"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
state: { status: "failed", error: "broken" },
|
||||
features: { server: true },
|
||||
},
|
||||
{
|
||||
id: "local.dual",
|
||||
source: { type: "local", path: "/tmp/local/index.ts" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
],
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"TUI",
|
||||
"/tmp/local (advertised)",
|
||||
"/tmp/local.ts (discovered)",
|
||||
"acme-plugin@1.0.0 (advertised)",
|
||||
"tui-only (configured)",
|
||||
"",
|
||||
"Server",
|
||||
"acme.dual (active)",
|
||||
"broken-plugin (failed)",
|
||||
"local.dual (active)",
|
||||
].join(EOL),
|
||||
)
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{
|
||||
id: "opencode.agent",
|
||||
source: { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
],
|
||||
[],
|
||||
true,
|
||||
),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
test("removes string and object package entries without replacing unrelated settings", async () => {
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "opencode.jsonc")
|
||||
await Bun.write(
|
||||
file,
|
||||
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
|
||||
)
|
||||
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
})
|
||||
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
|
||||
expect(output).not.toContain("$12.34")
|
||||
})
|
||||
|
||||
test("renders shared intensity levels and partial month labels", () => {
|
||||
const output = renderStats(
|
||||
{
|
||||
...stats,
|
||||
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
|
||||
activity: [{ date: "2026-04-29", steps: 1 }],
|
||||
},
|
||||
options(),
|
||||
)
|
||||
expect(output).toContain(" May")
|
||||
expect(output).not.toContain("Apr")
|
||||
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
|
||||
})
|
||||
|
||||
test("renders only requested detail tables", () => {
|
||||
const output = renderStats(stats, options({ tools: true, cost: true }))
|
||||
expect(output).toContain("COST & TOKENS")
|
||||
|
||||
@@ -198,7 +198,13 @@ test("Node distribution honors the compile-time CLI name", async () => {
|
||||
"--test-name-pattern",
|
||||
"^Node distribution resolves the published npm package$",
|
||||
],
|
||||
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
|
||||
{
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
// Bun 1.4 can reuse cached modules compiled with different --define values.
|
||||
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0" },
|
||||
},
|
||||
)
|
||||
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
||||
expect(code, stderr).toBe(0)
|
||||
|
||||
@@ -88,8 +88,24 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target?: string | undefined
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
}
|
||||
export type PluginUpdateOutput = void
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -322,7 +326,25 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -466,6 +470,32 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/check`,
|
||||
query: { location: input?.["location"] },
|
||||
body: { target: input?.["target"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -12,13 +12,13 @@ export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; package: string }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
|
||||
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string }
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -214,6 +214,7 @@ export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
@@ -1816,6 +1817,7 @@ export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
@@ -1993,6 +1995,7 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
@@ -2354,6 +2357,14 @@ export type AgentNotFoundError = {
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2415,14 +2426,6 @@ export type SkillNotFoundError = {
|
||||
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
@@ -2582,6 +2585,27 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target?: { readonly target?: string | undefined }["target"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = void
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
|
||||
@@ -147,7 +147,7 @@ function createSync() {
|
||||
state.set(key, entry)
|
||||
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
|
||||
.then(() => {
|
||||
if (state.get(key) === entry) state.set(key, true)
|
||||
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.get(key) === entry) state.delete(key)
|
||||
@@ -493,6 +493,33 @@ export function createData(config: CreateDataInput) {
|
||||
)
|
||||
}
|
||||
|
||||
function evictSession(sessionID: string) {
|
||||
if (sessionOutbox.has(sessionID)) return
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
messageLoads.delete(sessionID)
|
||||
// Keep unacknowledged submissions until their echo or rollback settles them.
|
||||
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
|
||||
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
|
||||
messageIndex.delete(sessionID)
|
||||
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
delete draft.message[sessionID]
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
if (messages.length) draft.message[sessionID] = messages
|
||||
if (pending.length) {
|
||||
draft.pending[sessionID] = pending
|
||||
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
activeUpdates?.set(sessionID, undefined)
|
||||
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
|
||||
@@ -1260,9 +1287,11 @@ export function createData(config: CreateDataInput) {
|
||||
return creating.has(sessionID)
|
||||
},
|
||||
remember(info: SessionInfo) {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
registerSession(info.id)
|
||||
batch(() => {
|
||||
setStore("session", "info", info.id, reconcile(info))
|
||||
sync.complete(`session:${info.id}`)
|
||||
registerSession(info.id)
|
||||
})
|
||||
},
|
||||
setStatus(sessionID: string, status: DataSessionStatus) {
|
||||
setSessionActive(sessionID, status)
|
||||
@@ -1273,6 +1302,13 @@ export function createData(config: CreateDataInput) {
|
||||
family(sessionID: string) {
|
||||
return store.session.family[resolveRoot(sessionID)] ?? []
|
||||
},
|
||||
/** Clear heavy cached data for the root and all known descendants. */
|
||||
evict(sessionID: string) {
|
||||
const root = resolveRoot(sessionID)
|
||||
batch(() => {
|
||||
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
|
||||
})
|
||||
},
|
||||
cost(sessionID: string) {
|
||||
const session = store.session.info[sessionID]
|
||||
if (!session) return 0
|
||||
@@ -1484,17 +1520,19 @@ export function createData(config: CreateDataInput) {
|
||||
: [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
batch(() => {
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
produce((draft) => {
|
||||
for (const session of sessions) draft[session.id] = session
|
||||
}),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
sync.complete(`session:${session.id}`)
|
||||
registerSession(session.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
|
||||
@@ -45,7 +45,6 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
|
||||
sessionID: "ses_test",
|
||||
key: "review-notes",
|
||||
})
|
||||
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
|
||||
|
||||
void [
|
||||
effectSession,
|
||||
@@ -55,7 +54,6 @@ void [
|
||||
promiseList,
|
||||
promisePut,
|
||||
promiseRemove,
|
||||
emptyRpcOutput,
|
||||
exactVersion,
|
||||
compatibleVersion,
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { Context, DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
AbsolutePath,
|
||||
@@ -135,6 +135,28 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
|
||||
test("shared event source runs with the Effect context captured by make", async () => {
|
||||
const connected = { id: "evt_connected", type: "server.connected", data: {} }
|
||||
const Token = Context.Reference("test/effect/token", { defaultValue: () => "missing" })
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const token = yield* Token
|
||||
expect(token).toBe("captured")
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, httpClient),
|
||||
Effect.provideService(Token, "captured"),
|
||||
),
|
||||
)
|
||||
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on Effect protocol decode failures", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { OpenCode } from "../src/effect/index"
|
||||
|
||||
const definition = Rpc.define({
|
||||
id: "example",
|
||||
methods: {
|
||||
count: {
|
||||
input: Schema.Struct({ count: Schema.FiniteFromString }),
|
||||
output: Schema.FiniteFromString,
|
||||
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
|
||||
},
|
||||
echo: { input: Schema.Json, output: Schema.Json },
|
||||
empty: { input: Schema.Undefined, output: Schema.Undefined },
|
||||
raw: { input: { type: "string" }, output: { type: "number" } },
|
||||
},
|
||||
events: {
|
||||
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
|
||||
message: { schema: Schema.Struct({ text: Schema.String }) },
|
||||
},
|
||||
})
|
||||
|
||||
const connected = { id: "evt_connected", type: "server.connected", data: {} }
|
||||
|
||||
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
|
||||
return {
|
||||
id: "evt_progress",
|
||||
created: 123,
|
||||
type: `rpc.${rpcID}.${name}`,
|
||||
location: { directory },
|
||||
metadata: { origin: "test" },
|
||||
data: { count },
|
||||
}
|
||||
}
|
||||
|
||||
function eventSource() {
|
||||
const requests: HttpClientRequest.HttpClientRequest[] = []
|
||||
const opened = Promise.withResolvers<{
|
||||
controller: ReadableStreamDefaultController<Uint8Array>
|
||||
signal: AbortSignal
|
||||
}>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
return {
|
||||
requests,
|
||||
opened: opened.promise,
|
||||
cancelled: cancelled.promise,
|
||||
async push(event: unknown) {
|
||||
const source = await opened.promise
|
||||
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
},
|
||||
httpClient: HttpClient.make((request, _url, signal) => {
|
||||
requests.push(request)
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
opened.resolve({ controller, signal })
|
||||
},
|
||||
cancel() {
|
||||
cancelled.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
|
||||
const requests: Array<{ url: string; body: unknown }> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
|
||||
requests.push({ url: request.url, body })
|
||||
return Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({
|
||||
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
|
||||
const rpc = client.rpc(definition)
|
||||
const count = yield* rpc.count({ count: "2" })
|
||||
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
|
||||
const empty = yield* rpc.empty()
|
||||
const raw = yield* rpc.raw("input")
|
||||
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
|
||||
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
|
||||
return { count, primitives, empty, raw, native }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({
|
||||
count: 42,
|
||||
primitives: [null, false, 0, "hello", [1, "two"]],
|
||||
empty: undefined,
|
||||
raw: 7,
|
||||
native: { output: "42" },
|
||||
})
|
||||
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
|
||||
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
|
||||
})
|
||||
|
||||
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
|
||||
const validations: unknown[] = []
|
||||
const standard = {
|
||||
"~standard": {
|
||||
version: 1 as const,
|
||||
vendor: "fixture",
|
||||
validate(value: unknown) {
|
||||
validations.push(value)
|
||||
return { value: String(value) + " transformed" }
|
||||
},
|
||||
},
|
||||
}
|
||||
const service = Rpc.define({
|
||||
id: "standard",
|
||||
methods: { transform: { input: standard, output: standard } },
|
||||
events: {
|
||||
transformed: {
|
||||
schema: {
|
||||
"~standard": {
|
||||
version: 1 as const,
|
||||
vendor: "fixture",
|
||||
validate(value: unknown) {
|
||||
validations.push(value)
|
||||
return { value: { text: String(value) + " transformed" } }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
request.url.endsWith("/api/event")
|
||||
? new Response(
|
||||
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
: Response.json({ output: "done" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const rpc = client.rpc(service)
|
||||
return {
|
||||
output: yield* rpc.transform("input"),
|
||||
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
|
||||
}
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result.output).toBe("done")
|
||||
expect(result.events[0].data).toEqual({ text: "done" })
|
||||
expect(validations).toEqual([])
|
||||
})
|
||||
|
||||
test("Effect RPC validates decoded outputs in the failure channel", async () => {
|
||||
const requests: string[] = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
requests.push(request.url)
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
|
||||
})
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(Schema.isSchemaError(error)).toBe(true)
|
||||
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
|
||||
})
|
||||
|
||||
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
|
||||
})
|
||||
|
||||
test("Effect RPC removes the internal transport wrapper", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
|
||||
})
|
||||
|
||||
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
|
||||
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
|
||||
const release = Promise.withResolvers<void>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const httpClient = HttpClient.make((request, url) => {
|
||||
requests.push({ url, headers: request.headers })
|
||||
if (requests.length === 1) started.resolve()
|
||||
return Effect.promise(() => release.promise).pipe(
|
||||
Effect.as(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
url.pathname.endsWith("/health")
|
||||
? Response.json({ healthy: true, version: "test", pid: 1 })
|
||||
: Response.json({ output: "3" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const first = Effect.runPromise(
|
||||
rpc.count(
|
||||
{ count: "1" },
|
||||
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
|
||||
),
|
||||
)
|
||||
await started.promise
|
||||
const second = Effect.runPromise(
|
||||
rpc.count(
|
||||
{ count: "2" },
|
||||
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(client.health.get())
|
||||
release.resolve()
|
||||
expect(await Promise.all([first, second])).toEqual([3, 3])
|
||||
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
|
||||
expect(requests.map((request) => request.headers.authorization)).toEqual([
|
||||
"Bearer base",
|
||||
"Bearer base",
|
||||
"Bearer base",
|
||||
])
|
||||
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
|
||||
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
|
||||
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
|
||||
"/project/one",
|
||||
"/project/two",
|
||||
null,
|
||||
])
|
||||
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
|
||||
})
|
||||
|
||||
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
|
||||
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
|
||||
Promise.withResolvers<AbortSignal>(),
|
||||
Promise.withResolvers<AbortSignal>(),
|
||||
]
|
||||
const signals: AbortSignal[] = []
|
||||
const finalized: number[] = []
|
||||
const httpClient = HttpClient.make((_request, _url, signal) => {
|
||||
const index = signals.length
|
||||
signals.push(signal)
|
||||
started[index].resolve(signal)
|
||||
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
|
||||
})
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const abort = new AbortController()
|
||||
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
|
||||
const second = Effect.runFork(rpc.count({ count: "2" }))
|
||||
await Promise.all(started.map((entry) => entry.promise))
|
||||
abort.abort()
|
||||
const exit = await Effect.runPromise(Fiber.await(first))
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
|
||||
expect(finalized).toEqual([0])
|
||||
await Effect.runPromise(Fiber.interrupt(second))
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
expect(finalized).toEqual([0, 1])
|
||||
|
||||
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
|
||||
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
|
||||
expect(signals).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const rpc = client.rpc(definition)
|
||||
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
|
||||
expect(source.requests).toHaveLength(0)
|
||||
const marker = native.next()
|
||||
await source.push(connected)
|
||||
expect((await marker).value).toEqual(connected)
|
||||
|
||||
const first = progress.next()
|
||||
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
expect((await late.next()).value).toEqual(connected)
|
||||
await native.return?.()
|
||||
await late.return?.()
|
||||
await source.push(rpcEvent("ignored", "/project/one", "other"))
|
||||
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
|
||||
await source.push(rpcEvent("1"))
|
||||
expect((await first).value).toEqual({
|
||||
id: "evt_progress",
|
||||
created: 123,
|
||||
type: "rpc.example.progress",
|
||||
metadata: { origin: "test" },
|
||||
data: { count: 1 },
|
||||
location: { directory: "/project/one" },
|
||||
})
|
||||
const second = progress.next()
|
||||
await source.push(rpcEvent("2", "/project/two"))
|
||||
expect((await second).value).toEqual(
|
||||
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
|
||||
)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const third = progress.next()
|
||||
await source.push(rpcEvent("3"))
|
||||
expect((await third).value.data).toEqual({ count: 3 })
|
||||
const pending = progress.next()
|
||||
await progress.return?.()
|
||||
expect((await pending).done).toBe(true)
|
||||
await source.cancelled
|
||||
expect((await source.opened).signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
|
||||
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
|
||||
const first = progress.next()
|
||||
await source.push(rpcEvent("1"))
|
||||
expect((await first).value.data).toEqual({ count: 1 })
|
||||
await Effect.runPromise(Fiber.interrupt(native))
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const second = progress.next()
|
||||
await source.push(rpcEvent("2"))
|
||||
expect((await second).value.data).toEqual({ count: 2 })
|
||||
await progress.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("shared Effect streams preserve EOF without reconnecting", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
|
||||
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
|
||||
await source.push(connected)
|
||||
await source.push(rpcEvent("1"))
|
||||
const connection = await source.opened
|
||||
connection.controller.close()
|
||||
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
|
||||
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push({ type: "server.connected" })
|
||||
expect((await native)._tag).toBe("ClientError")
|
||||
expect(await progress).toBe(await native)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("HTTP source failures reach every Effect consumer", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push(connected)
|
||||
const connection = await source.opened
|
||||
connection.controller.error(new Error("connection lost"))
|
||||
expect((await native)._tag).toBe("ClientError")
|
||||
expect(await progress).toBe(await native)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
|
||||
const source = eventSource()
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, source.httpClient),
|
||||
),
|
||||
)
|
||||
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
|
||||
const raw = native.next()
|
||||
const progress = Effect.runPromise(
|
||||
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
|
||||
)
|
||||
await source.push(rpcEvent("not a number"))
|
||||
expect((await raw).value.type).toBe("rpc.example.progress")
|
||||
expect(Schema.isSchemaError(await progress)).toBe(true)
|
||||
expect((await source.opened).signal.aborted).toBe(false)
|
||||
const next = native.next()
|
||||
await source.push(connected)
|
||||
expect((await next).value.type).toBe("server.connected")
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("shared event source runs with the Effect context captured by make", async () => {
|
||||
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const token = yield* Token
|
||||
expect(token).toBe("captured")
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const client = await Effect.runPromise(
|
||||
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
|
||||
Effect.provideService(HttpClient.HttpClient, httpClient),
|
||||
Effect.provideService(Token, "captured"),
|
||||
),
|
||||
)
|
||||
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
|
||||
})
|
||||
|
||||
test("Effect RPC rejects inherited event names without opening the source", async () => {
|
||||
const requests: string[] = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
requests.push(request.url)
|
||||
return Effect.die(new Error("Unexpected request"))
|
||||
})
|
||||
const error = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const broad: Rpc.Definition = definition
|
||||
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { z } from "zod"
|
||||
import { OpenCode } from "../src/promise/index"
|
||||
|
||||
const cleanup = new Set<() => void>()
|
||||
afterEach(() => {
|
||||
cleanup.forEach((close) => close())
|
||||
cleanup.clear()
|
||||
})
|
||||
|
||||
const Echo = Rpc.define({
|
||||
id: "acme/jobs",
|
||||
methods: {
|
||||
echo: {
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
errors: { rejected: z.object({ reason: z.string() }) },
|
||||
},
|
||||
raw: { input: z.unknown(), output: z.unknown() },
|
||||
ping: { input: z.undefined(), output: z.undefined() },
|
||||
},
|
||||
events: {
|
||||
updated: { schema: z.object({ count: z.number() }) },
|
||||
},
|
||||
})
|
||||
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
|
||||
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
|
||||
id: "evt_rpc",
|
||||
created: 10,
|
||||
type: `rpc.${rpcID}.${name}`,
|
||||
location: { directory },
|
||||
metadata: { source: "test" },
|
||||
data,
|
||||
})
|
||||
function http(fetch: (request: Request) => Response | Promise<Response>) {
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
|
||||
cleanup.add(() => server.stop(true))
|
||||
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
|
||||
}
|
||||
|
||||
function events() {
|
||||
const requests: Request[] = []
|
||||
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const cancelled = Promise.withResolvers<void>()
|
||||
const encoder = new TextEncoder()
|
||||
let stopped = false
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
headers: { authorization: "Bearer events" },
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const abort = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
controller.error(request.signal.reason)
|
||||
cancelled.resolve()
|
||||
}
|
||||
request.signal.addEventListener("abort", abort, { once: true })
|
||||
cleanup.add(abort)
|
||||
opened.resolve(controller)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
|
||||
},
|
||||
cancel() {
|
||||
stopped = true
|
||||
cancelled.resolve()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
|
||||
},
|
||||
})
|
||||
return {
|
||||
client,
|
||||
requests,
|
||||
cancelled: cancelled.promise,
|
||||
async send(value: unknown) {
|
||||
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
|
||||
},
|
||||
async end() {
|
||||
stopped = true
|
||||
return (await opened.promise).close()
|
||||
},
|
||||
async fail(error: Error) {
|
||||
stopped = true
|
||||
return (await opened.promise).error(error)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
|
||||
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
|
||||
const client = http(async (request) => {
|
||||
const body = await request.json()
|
||||
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
|
||||
return Response.json({ output: body.input })
|
||||
})
|
||||
expect(typeof client.rpc).toBe("function")
|
||||
expect(typeof client.rpc.call).toBe("function")
|
||||
expect(
|
||||
await client.rpc(Echo).echo("hello", {
|
||||
location: { directory: "/project with spaces", workspace: "wrk_test" },
|
||||
headers: { authorization: "Bearer override", "x-call": "call" },
|
||||
}),
|
||||
).toBe("hello")
|
||||
const url = new URL(requests[0].url)
|
||||
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
|
||||
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
|
||||
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
|
||||
expect(requests[0].body).toEqual({ input: "hello" })
|
||||
expect(requests[0].method).toBe("POST")
|
||||
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
|
||||
expect(requests[0].headers.get("x-base")).toBe("base")
|
||||
expect(requests[0].headers.get("x-call")).toBe("call")
|
||||
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
|
||||
expect(new URL(requests[1].url).search).toBe("")
|
||||
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
|
||||
})
|
||||
|
||||
test("no-input RPC methods and absent output use empty wrappers", async () => {
|
||||
const client = http(async (request) => {
|
||||
expect(await request.json()).toEqual({})
|
||||
return Response.json({})
|
||||
})
|
||||
expect(await client.rpc(Echo).ping()).toBeUndefined()
|
||||
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
|
||||
const calls = { input: 0, output: 0 }
|
||||
const input: StandardSchemaV1<string, number> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
calls.input++
|
||||
return { value: Number(value) }
|
||||
},
|
||||
},
|
||||
}
|
||||
const output: StandardSchemaV1<number, string> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
calls.output++
|
||||
return { value: String(value) }
|
||||
},
|
||||
},
|
||||
}
|
||||
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value) => {
|
||||
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
|
||||
return { issues: [{ message: "Expected count" }] }
|
||||
return { value: { text: String(value.count) } }
|
||||
},
|
||||
},
|
||||
}
|
||||
const definition = Rpc.define({
|
||||
id: "standard",
|
||||
methods: { count: { input, output } },
|
||||
events: { counted: { schema: eventOutput } },
|
||||
})
|
||||
const client = http(async (request) => {
|
||||
expect(await request.json()).toEqual({ input: "41" })
|
||||
return Response.json({ output: "42" })
|
||||
})
|
||||
expect(await client.rpc(definition).count("41")).toBe("42")
|
||||
const source = events()
|
||||
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
|
||||
const next = iterator.next()
|
||||
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
|
||||
expect((await next).value?.data).toEqual({ text: "42" })
|
||||
await iterator.return?.()
|
||||
expect(calls).toEqual({ input: 0, output: 0 })
|
||||
})
|
||||
|
||||
test("RPC method signals cancel an in-flight HTTP request", async () => {
|
||||
const received = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const client = http(() => {
|
||||
received.resolve()
|
||||
return response.promise
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = client
|
||||
.rpc(Echo)
|
||||
.echo("hello", { signal: controller.signal })
|
||||
.catch((error: unknown) => error)
|
||||
await received.promise
|
||||
controller.abort()
|
||||
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
|
||||
response.resolve(Response.json({ output: "late" }))
|
||||
})
|
||||
|
||||
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
|
||||
let requests = 0
|
||||
const client = http(() => {
|
||||
requests++
|
||||
return Response.json({ output: "hello" })
|
||||
})
|
||||
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
|
||||
expect(requests).toBe(0)
|
||||
})
|
||||
|
||||
test("RPC declared HTTP failures propagate", async () => {
|
||||
await expect(
|
||||
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
|
||||
.rpc(Echo)
|
||||
.echo("hello"),
|
||||
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
|
||||
})
|
||||
|
||||
test("RPC method failures remove the generic transport wrapper", async () => {
|
||||
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
|
||||
const client = http(() => Response.json(response, { status: 400 }))
|
||||
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
|
||||
|
||||
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
|
||||
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
|
||||
})
|
||||
|
||||
test("RPC transport failures remove the generic transport wrapper", async () => {
|
||||
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
|
||||
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
|
||||
type: "rpc.internal",
|
||||
message: "Failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("native events and multiple RPC clients share one lazy source across locations", async () => {
|
||||
const source = events()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
|
||||
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
expect(source.requests).toHaveLength(0)
|
||||
const firstNext = first.next()
|
||||
const secondNext = second.next()
|
||||
const otherNext = other.next()
|
||||
expect(await native.next()).toEqual({ done: false, value: connected })
|
||||
expect(source.requests).toHaveLength(1)
|
||||
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
|
||||
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
expect(await late.next()).toEqual({ done: false, value: connected })
|
||||
await Promise.all([native.return?.(), late.return?.()])
|
||||
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
|
||||
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
|
||||
expect((await otherNext).value).toMatchObject({
|
||||
type: "rpc.other.updated",
|
||||
location: { directory: "/other" },
|
||||
data: { count: 9 },
|
||||
})
|
||||
await other.return?.()
|
||||
await source.send(rpcEvent({ count: 42 }))
|
||||
const expected = {
|
||||
id: "evt_rpc",
|
||||
created: 10,
|
||||
type: `rpc.${Echo.id}.updated`,
|
||||
location: { directory: "/first" },
|
||||
metadata: { source: "test" },
|
||||
data: { count: 42 },
|
||||
}
|
||||
expect(await firstNext).toEqual({ done: false, value: expected })
|
||||
expect(await secondNext).toEqual({ done: false, value: expected })
|
||||
const next = first.next()
|
||||
await source.send(rpcEvent({ count: 43 }, "/second"))
|
||||
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
|
||||
await Promise.all([first.return?.(), second.return?.()])
|
||||
await source.cancelled
|
||||
expect(source.requests[0].signal.aborted).toBe(true)
|
||||
expect(source.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
|
||||
const source = events()
|
||||
const controller = new AbortController()
|
||||
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
|
||||
const second = secondEvents[Symbol.asyncIterator]()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
const firstNext = first.next()
|
||||
const secondNext = second.next()
|
||||
await native.next()
|
||||
expect((await first.return?.())?.done).toBe(true)
|
||||
expect((await firstNext).done).toBe(true)
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
controller.abort()
|
||||
expect((await secondNext).done).toBe(true)
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
const nativeNext = native.next()
|
||||
const event = rpcEvent({ count: 42 })
|
||||
await source.send(event)
|
||||
expect(await nativeNext).toEqual({ done: false, value: event })
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("RPC callback subscriptions unsubscribe independently", async () => {
|
||||
const source = events()
|
||||
const received = Promise.withResolvers<unknown>()
|
||||
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
|
||||
await native.next()
|
||||
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
|
||||
await source.send(rpcEvent({ count: 42 }))
|
||||
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
expect(source.requests[0].signal.aborted).toBe(false)
|
||||
await native.return?.()
|
||||
await source.cancelled
|
||||
})
|
||||
|
||||
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
|
||||
const source = events()
|
||||
const client = source.client.rpc(Echo)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const failed: number[] = []
|
||||
cleanup.add(release.resolve)
|
||||
cleanup.add(
|
||||
client.events.on("updated", async (event) => {
|
||||
failed.push(event.data.count)
|
||||
started.resolve()
|
||||
await release.promise
|
||||
throw new Error("Expected async RPC callback failure")
|
||||
}),
|
||||
)
|
||||
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
|
||||
const first = healthy.next()
|
||||
await source.send(rpcEvent({ count: 1 }))
|
||||
await started.promise
|
||||
expect((await first).value.data.count).toBe(1)
|
||||
const second = healthy.next()
|
||||
await source.send(rpcEvent({ count: 2 }))
|
||||
expect((await second).value.data.count).toBe(2)
|
||||
expect(failed).toEqual([1])
|
||||
release.resolve()
|
||||
await healthy.return?.()
|
||||
await source.cancelled
|
||||
expect(failed).toEqual([1])
|
||||
})
|
||||
|
||||
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
|
||||
const source = events()
|
||||
const broad: Rpc.PortableDefinition = Echo
|
||||
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
|
||||
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
|
||||
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
|
||||
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
|
||||
const iterator = aborted[Symbol.asyncIterator]()
|
||||
expect((await iterator.next()).done).toBe(true)
|
||||
expect(source.requests).toHaveLength(0)
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("evicts a whole family while preserving metadata and attention", async () => {
|
||||
const setup = fixture()
|
||||
try {
|
||||
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
|
||||
await setup.data.session.message.sync(id)
|
||||
await setup.data.session.pending.sync(id)
|
||||
}
|
||||
setup.data.session.setStatus("ses_child", "running")
|
||||
setup.emit({
|
||||
id: "evt_permission",
|
||||
created: 1,
|
||||
type: "permission.asked",
|
||||
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
|
||||
})
|
||||
|
||||
setup.data.session.evict("ses_parent")
|
||||
|
||||
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
|
||||
expect(setup.data.session.message.list(id)).toEqual([])
|
||||
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
|
||||
expect(setup.data.session.message.more(id)).toBe(false)
|
||||
expect(setup.data.session.message.loading(id)).toBe(false)
|
||||
expect(setup.data.session.pending.list(id)).toEqual([])
|
||||
expect(setup.data.session.input.list(id)).toEqual([])
|
||||
expect(setup.data.session.get(id)?.id).toBe(id)
|
||||
}
|
||||
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
|
||||
expect(setup.data.session.status("ses_child")).toBe("running")
|
||||
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
|
||||
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_agent",
|
||||
created: 1,
|
||||
type: "session.agent.selected",
|
||||
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_parent", agent: "build" },
|
||||
})
|
||||
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
|
||||
await setup.data.session.message.sync("ses_parent")
|
||||
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
|
||||
await setup.data.session.message.sync("ses_grandchild")
|
||||
await setup.data.session.pending.sync("ses_grandchild")
|
||||
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
|
||||
expect(setup.data.session.message.list("ses_child")).toEqual([])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("evicting an in-flight read leaves the next sync invalidated", async () => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let requests = 0
|
||||
const setup = fixture(async () => {
|
||||
if (++requests !== 1) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
})
|
||||
try {
|
||||
const initial = setup.data.session.message.sync("ses_child")
|
||||
await requested.promise
|
||||
setup.data.session.evict("ses_parent")
|
||||
release.resolve()
|
||||
await initial
|
||||
await setup.data.session.message.sync("ses_child")
|
||||
expect(requests).toBe(2)
|
||||
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const setup = fixture(async (url) => {
|
||||
if (!url.pathname.endsWith("/prompt")) return undefined
|
||||
await release.promise
|
||||
return mode === "reject"
|
||||
? Response.json({ message: "rejected" }, { status: 400 })
|
||||
: Response.json({ id: "msg_local" })
|
||||
})
|
||||
try {
|
||||
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
|
||||
const settled = Promise.allSettled([request])
|
||||
setup.data.session.evict("ses_parent")
|
||||
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
|
||||
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
|
||||
release.resolve()
|
||||
await settled
|
||||
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
|
||||
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function info(id: string, parentID?: string): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
parentID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
location: { directory: "/project" },
|
||||
}
|
||||
}
|
||||
|
||||
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
|
||||
return {
|
||||
id: `evt_${inboxID}`,
|
||||
created: 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(read?: (url: URL) => Promise<Response | void>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
const response = await read?.(url)
|
||||
if (response) return response
|
||||
const sessionID = url.pathname.split("/")[3]
|
||||
if (url.pathname.endsWith("/inbox"))
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_pending",
|
||||
sessionID,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "pending" },
|
||||
timeCreated: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
return Response.json({
|
||||
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
|
||||
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
|
||||
})
|
||||
},
|
||||
})
|
||||
return createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
})
|
||||
data.session.remember(info("ses_parent"))
|
||||
data.session.remember(info("ses_child", "ses_parent"))
|
||||
data.session.remember(info("ses_grandchild", "ses_child"))
|
||||
data.session.remember(info("ses_other"))
|
||||
return {
|
||||
data,
|
||||
dispose,
|
||||
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
CodeModeFunction,
|
||||
CodeModeGenerator,
|
||||
CoercionFunction,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
GeneratorMethodReference,
|
||||
InterpreterRuntimeError,
|
||||
IntrinsicReference,
|
||||
IteratorSymbol,
|
||||
JsonMethodReference,
|
||||
PromiseCapabilityFunction,
|
||||
PromiseInstanceMethodReference,
|
||||
@@ -42,13 +44,12 @@ export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof SymbolNamespace ||
|
||||
isCodeModeValue(value)
|
||||
|
||||
function* childValues(value: object): Generator<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
const length = value.length
|
||||
for (let index = 0; index < length; index++) yield value[index]
|
||||
return
|
||||
function* childValues(value: object): Generator {
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
|
||||
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
yield Reflect.get(value, key)
|
||||
}
|
||||
yield* Object.values(value)
|
||||
}
|
||||
|
||||
export const containsRuntimeReference = (value: unknown): boolean => {
|
||||
@@ -90,9 +91,14 @@ export const containsOpaqueReference = (value: unknown): boolean => {
|
||||
}
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const seen = new Set<object>()
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
@@ -104,7 +110,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
|
||||
pending.push(childValues(current))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
InterpreterRuntimeError,
|
||||
IteratorSymbol,
|
||||
IteratorSymbols,
|
||||
} from "../interpreter/model.js"
|
||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isCodeModeValue, CodeModePromise } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
@@ -37,10 +31,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
}
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys":
|
||||
return Object.keys(requireObject())
|
||||
@@ -64,14 +54,29 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
const seen = new Set<object>()
|
||||
const guardedSet = (key: PropertyKey, item: unknown): void => {
|
||||
if (typeof key === "string" && isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
|
||||
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
|
||||
if (!Reflect.set(out, key, item))
|
||||
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
|
||||
"TypeError",
|
||||
)
|
||||
}
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined || isCodeModeValue(source)) continue
|
||||
if (typeof source !== "object" || Array.isArray(source)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
|
||||
for (const symbol of IteratorSymbols) {
|
||||
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
|
||||
for (const key of Reflect.ownKeys(source)) {
|
||||
if (typeof key === "string") {
|
||||
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
|
||||
continue
|
||||
}
|
||||
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
|
||||
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
|
||||
guardedSet(key, Reflect.get(source, key))
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -63,21 +63,58 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
} catch {}
|
||||
}
|
||||
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
|
||||
if (schema.type === "integer") tags.push("@integer")
|
||||
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
|
||||
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
|
||||
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
|
||||
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
|
||||
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
|
||||
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
|
||||
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
|
||||
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
|
||||
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
|
||||
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
|
||||
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
|
||||
return tags
|
||||
}
|
||||
|
||||
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
|
||||
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
|
||||
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
|
||||
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
|
||||
)
|
||||
const docLines = (schema: JsonSchema, width: number): Array<string> => {
|
||||
const summary = docTags(schema).join(" ")
|
||||
const lines = (schema.description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
|
||||
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
|
||||
if (lines.length === 0) return ""
|
||||
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
|
||||
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
|
||||
const inline = lines.length === 1 ? `${lines[0]}${lines[0].endsWith(".") ? "" : "."} ${summary}` : summary
|
||||
return summary && lines.length === 1 && !summary.includes("\n") && width + inline.length + 7 <= 120
|
||||
? [inline]
|
||||
: [...lines, ...(summary ? summary.split("\n") : [])]
|
||||
}
|
||||
|
||||
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
|
||||
const jsdoc = (schema: JsonSchema, pad: string): string => {
|
||||
const content = docLines(schema, pad.length)
|
||||
const types = typeof schema.type === "string" ? [schema.type] : (schema.type ?? [])
|
||||
|
||||
const append = (label: string, child: JsonSchema) => {
|
||||
docLines(child, pad.length + label.length + 2).forEach((line, index) => {
|
||||
if (index === 0) {
|
||||
content.push(`${label}: ${line}`)
|
||||
return
|
||||
}
|
||||
content.push(line ? ` ${line}` : "")
|
||||
})
|
||||
}
|
||||
|
||||
// Document only the immediate contents; recursive labels obscure which level a constraint belongs to.
|
||||
if (types.includes("array") && schema.items) append("Each item", schema.items)
|
||||
if ((types.includes("object") || schema.properties) && typeof schema.additionalProperties === "object") {
|
||||
const label = Object.keys(schema.properties ?? {}).length > 0 ? "Each additional value" : "Each value"
|
||||
append(label, schema.additionalProperties)
|
||||
}
|
||||
|
||||
if (content.length === 0) return ""
|
||||
const escaped = content.map((line) => line.replaceAll("*/", "* /"))
|
||||
if (escaped.length === 1 && pad.length + escaped[0].length + 7 <= 120) return `${pad}/** ${escaped[0]} */\n`
|
||||
const body = escaped.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
|
||||
return `${pad}/**\n${body}\n${pad} */\n`
|
||||
}
|
||||
|
||||
@@ -156,9 +193,7 @@ const renderSchema = (
|
||||
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map(
|
||||
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
|
||||
)
|
||||
const lines = properties.map((entry) => `${jsdoc(entry[1], pad)}${pad}${field(entry)},`)
|
||||
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
|
||||
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
|
||||
}
|
||||
|
||||
@@ -21,8 +21,17 @@ export type JsonSchema = {
|
||||
readonly default?: unknown
|
||||
readonly format?: string
|
||||
readonly deprecated?: boolean
|
||||
readonly minimum?: number
|
||||
readonly maximum?: number
|
||||
readonly exclusiveMinimum?: number
|
||||
readonly exclusiveMaximum?: number
|
||||
readonly multipleOf?: number
|
||||
readonly minLength?: number
|
||||
readonly maxLength?: number
|
||||
readonly pattern?: string
|
||||
readonly minItems?: number
|
||||
readonly maxItems?: number
|
||||
readonly uniqueItems?: boolean
|
||||
readonly $ref?: string
|
||||
readonly $defs?: Readonly<Record<string, JsonSchema>>
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
|
||||
@@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
|
||||
})
|
||||
|
||||
describe("pretty signature rendering", () => {
|
||||
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
|
||||
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
|
||||
expect(inputTypeScript(listIssues, true)).toBe(
|
||||
[
|
||||
"{",
|
||||
@@ -44,16 +44,9 @@ describe("pretty signature rendering", () => {
|
||||
" owner: string,",
|
||||
" /** Cursor from the previous response's pageInfo */",
|
||||
" after?: string,",
|
||||
" /**",
|
||||
" * Results per page",
|
||||
" * @default 30",
|
||||
" */",
|
||||
" /** Results per page. @default 30 */",
|
||||
" perPage?: number,",
|
||||
" /**",
|
||||
" * Filter by labels",
|
||||
" * @minItems 1",
|
||||
" * @maxItems 10",
|
||||
" */",
|
||||
" /** Filter by labels. @minItems 1 @maxItems 10 */",
|
||||
" labels?: Array<string>,",
|
||||
' state?: "open" | "closed",',
|
||||
"}",
|
||||
@@ -105,7 +98,7 @@ describe("pretty signature rendering", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
|
||||
test("constraints and annotations share compact tagged JSDoc", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "object",
|
||||
@@ -119,19 +112,10 @@ describe("pretty signature rendering", () => {
|
||||
)
|
||||
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
|
||||
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
|
||||
expect(pretty).toContain(
|
||||
[
|
||||
" /**",
|
||||
' * @default ["a","b"]',
|
||||
" * @minItems 2",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" tags?: Array<string>",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(pretty).toContain(' /** @default ["a","b"] @minItems 2 @maxItems 5 */\n tags?: Array<string>')
|
||||
})
|
||||
|
||||
test("skips an unserializable default rather than emitting a broken tag", () => {
|
||||
test("skips an unserializable default rather than emitting a broken summary", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{ type: "object", properties: { size: { type: "number", default: 1n } } },
|
||||
true,
|
||||
@@ -139,6 +123,248 @@ describe("pretty signature rendering", () => {
|
||||
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
|
||||
})
|
||||
|
||||
test("labels immediate item and dictionary value metadata above the field", () => {
|
||||
const schema = {
|
||||
properties: {
|
||||
recipients: {
|
||||
type: "array",
|
||||
description: "People to notify",
|
||||
minItems: 1,
|
||||
items: { type: "string", description: "Email address", format: "email" },
|
||||
},
|
||||
scores: { type: "object", additionalProperties: { type: "number", minimum: 0 } },
|
||||
names: { type: "array", items: { type: "string", description: "Display name" } },
|
||||
plain: { type: "object", additionalProperties: { type: "string" } },
|
||||
},
|
||||
}
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
" * People to notify. @minItems 1",
|
||||
" * Each item: Email address. @format email",
|
||||
" */",
|
||||
" recipients?: Array<string>,",
|
||||
" /** Each value: @minimum 0 */",
|
||||
" scores?: {",
|
||||
" [key: string]: number,",
|
||||
" },",
|
||||
" /** Each item: Display name */",
|
||||
" names?: Array<string>,",
|
||||
" plain?: {",
|
||||
" [key: string]: string,",
|
||||
" },",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe(
|
||||
"{ recipients?: Array<string>; scores?: { [key: string]: number }; names?: Array<string>; plain?: { [key: string]: string } }",
|
||||
)
|
||||
})
|
||||
|
||||
test("does not apply additional property metadata to named properties", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
properties: {
|
||||
scores: {
|
||||
type: "object",
|
||||
properties: { title: { type: "string", description: "Score title" } },
|
||||
additionalProperties: { type: "number", minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** Each additional value: @minimum 0 */",
|
||||
" scores?: {",
|
||||
" /** Score title */",
|
||||
" title?: string,",
|
||||
" [key: string]: number,",
|
||||
" },",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("labels array and dictionary contents in type-array unions", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
properties: {
|
||||
recipients: { type: ["array", "null"], items: { type: "string", format: "email" } },
|
||||
scores: { type: ["object", "null"], additionalProperties: { type: "number", minimum: 0 } },
|
||||
either: {
|
||||
type: ["array", "object"],
|
||||
items: { type: "string", minLength: 1 },
|
||||
additionalProperties: { type: "number", minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** Each item: @format email */",
|
||||
" recipients?: Array<string> | null,",
|
||||
" /** Each value: @minimum 0 */",
|
||||
" scores?: {",
|
||||
" [key: string]: number,",
|
||||
" } | null,",
|
||||
" /**",
|
||||
" * Each item: @minLength 1",
|
||||
" * Each value: @minimum 0",
|
||||
" */",
|
||||
" either?: Array<string> | {",
|
||||
" [key: string]: number,",
|
||||
" },",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps multiline item metadata indented under its label and escapes terminators", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
properties: {
|
||||
values: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
items: { type: "string", description: "\nFirst */ line\n\nSecond line\n", pattern: "^\\d+\n*/$" },
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
" * @minItems 1",
|
||||
" * Each item: First * / line",
|
||||
" *",
|
||||
" * Second line",
|
||||
" * @pattern ^\\d+",
|
||||
" * * /$",
|
||||
" */",
|
||||
" values?: Array<string>,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("does not flatten nested containers, reference targets, or branches into item metadata", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
$defs: { Email: { type: "string", format: "email" } },
|
||||
properties: {
|
||||
matrix: { type: "array", items: { type: "array", minItems: 2, items: { type: "integer", minimum: 0 } } },
|
||||
refs: { type: "array", items: { $ref: "#/$defs/Email", description: "Recipient" } },
|
||||
choices: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: "string", minLength: 1 },
|
||||
{ type: "number", minimum: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** Each item: @minItems 2 */",
|
||||
" matrix?: Array<Array<number>>,",
|
||||
" /** Each item: Recipient */",
|
||||
" refs?: Array<string>,",
|
||||
" choices?: Array<string | number>,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
|
||||
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
|
||||
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
|
||||
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
|
||||
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
|
||||
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
|
||||
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
|
||||
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
|
||||
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
|
||||
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
|
||||
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
|
||||
[{ type: "string", minLength: 0, maxLength: 0 }, "@minLength 0 @maxLength 0", "string"],
|
||||
[{ type: "array", minItems: 0, maxItems: 0 }, "@minItems 0 @maxItems 0", "Array<unknown>"],
|
||||
[
|
||||
{ type: "array", minItems: 1, maxItems: 10, uniqueItems: true },
|
||||
"@minItems 1 @maxItems 10 @uniqueItems true",
|
||||
"Array<unknown>",
|
||||
],
|
||||
] as const)("renders constraint %j without changing the compact type", (value, summary, type) => {
|
||||
const schema = { type: "object", properties: { value } }
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
|
||||
)
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
|
||||
})
|
||||
|
||||
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer" },
|
||||
amount: { type: "number" },
|
||||
name: { type: "string" },
|
||||
enabled: { type: "boolean" },
|
||||
values: { type: "array", uniqueItems: false },
|
||||
choice: { type: ["integer", "string"] },
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /** @integer */",
|
||||
" count?: number,",
|
||||
" amount?: number,",
|
||||
" name?: string,",
|
||||
" enabled?: boolean,",
|
||||
" values?: Array<unknown>,",
|
||||
" choice?: number | string,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
|
||||
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
|
||||
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
|
||||
)
|
||||
})
|
||||
|
||||
test("escapes comment terminators in summary values", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
|
||||
true,
|
||||
),
|
||||
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
|
||||
})
|
||||
|
||||
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
|
||||
@@ -161,6 +387,88 @@ describe("pretty signature rendering", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves inclusive and exclusive numeric bounds together", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
properties: {
|
||||
value: {
|
||||
type: "integer",
|
||||
minimum: -10,
|
||||
maximum: 10,
|
||||
exclusiveMinimum: -5,
|
||||
exclusiveMaximum: 5,
|
||||
multipleOf: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toContain(
|
||||
" /** @integer @minimum -10 @maximum 10 @exclusiveMinimum -5 @exclusiveMaximum 5 @multipleOf 2 */\n value?: number,",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["Maximum attempts", "Maximum attempts."],
|
||||
["Maximum attempts.", "Maximum attempts."],
|
||||
["Maximum attempts!", "Maximum attempts!."],
|
||||
])("combines a short description (%s) with its summary", (description, expected) => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{ properties: { attempts: { description, type: "integer", minimum: 1, default: 3 } } },
|
||||
true,
|
||||
),
|
||||
).toContain(` /** ${expected} @default 3 @integer @minimum 1 */\n`)
|
||||
})
|
||||
|
||||
test("keeps multiline descriptions intact and appends a compact summary", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
properties: {
|
||||
attempts: {
|
||||
description: "\nMaximum attempts\n\nIncludes the initial request.\n",
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
" * Maximum attempts",
|
||||
" *",
|
||||
" * Includes the initial request.",
|
||||
" * @integer @minimum 1",
|
||||
" */",
|
||||
" attempts?: number,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("uses a block for long descriptions without truncating or rewriting them", () => {
|
||||
const description = "A detailed description. ".repeat(8).trim()
|
||||
expect(jsonSchemaToTypeScript({ properties: { name: { type: "string", description, minLength: 1 } } }, true)).toBe(
|
||||
["{", " /**", ` * ${description}`, " * @minLength 1", " */", " name?: string,", "}"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves pattern backslashes and prefixes every line of multiline summary values", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{ properties: { value: { type: "string", pattern: "^\\d+\n*/$", default: "a\nb" } } },
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
["{", " /**", ' * @default "a\\nb" @pattern ^\\d+', " * * /$", " */", " value?: string,", "}"].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
|
||||
const cyclic = {
|
||||
$ref: "#/$defs/Node",
|
||||
@@ -377,6 +685,68 @@ describe("union schemas render every alternative", () => {
|
||||
})
|
||||
|
||||
describe("JSDoc signatures in catalogs and search results", () => {
|
||||
test.each([
|
||||
{
|
||||
source: "JSON Schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
count: { type: "integer", minimum: 0, maximum: 10 },
|
||||
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
|
||||
labels: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, maxItems: 5 },
|
||||
scores: { type: "object", additionalProperties: { type: "integer", minimum: 0 } },
|
||||
},
|
||||
required: ["count", "name", "labels", "scores"],
|
||||
},
|
||||
},
|
||||
{
|
||||
source: "Effect",
|
||||
schema: Schema.Struct({
|
||||
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
|
||||
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
|
||||
labels: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(
|
||||
Schema.isMinLength(1),
|
||||
Schema.isMaxLength(5),
|
||||
),
|
||||
scores: Schema.Record(Schema.String, Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
}),
|
||||
},
|
||||
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
constrained: Tool.make({
|
||||
description: "Constrained tool",
|
||||
input: schema,
|
||||
output: schema,
|
||||
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"], scores: { test: 1 } }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const type = [
|
||||
"{",
|
||||
" /** @integer @minimum 0 @maximum 10 */",
|
||||
" count: number,",
|
||||
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
|
||||
" name: string,",
|
||||
" /**",
|
||||
" * @minItems 1 @maxItems 5",
|
||||
" * Each item: @minLength 1",
|
||||
" */",
|
||||
" labels: Array<string>,",
|
||||
" /** Each value: @integer @minimum 0 */",
|
||||
" scores: {",
|
||||
" [key: string]: number,",
|
||||
" },",
|
||||
"}",
|
||||
].join("\n")
|
||||
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
|
||||
expect(runtime.catalog()[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
||||
|
||||
const search = async (query: string) => {
|
||||
@@ -386,7 +756,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
|
||||
}
|
||||
|
||||
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
|
||||
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and summaries", async () => {
|
||||
const { items } = await search("list issues repository")
|
||||
const item = items.find(({ path }) => path === "tools.github.list_issues")!
|
||||
expect(item.signature).toBe(
|
||||
@@ -396,16 +766,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
" owner: string,",
|
||||
" /** Cursor from the previous response's pageInfo */",
|
||||
" after?: string,",
|
||||
" /**",
|
||||
" * Results per page",
|
||||
" * @default 30",
|
||||
" */",
|
||||
" /** Results per page. @default 30 */",
|
||||
" perPage?: number,",
|
||||
" /**",
|
||||
" * Filter by labels",
|
||||
" * @minItems 1",
|
||||
" * @maxItems 10",
|
||||
" */",
|
||||
" /** Filter by labels. @minItems 1 @maxItems 10 */",
|
||||
" labels?: Array<string>,",
|
||||
' state?: "open" | "closed",',
|
||||
"}): Promise<unknown>",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
|
||||
import { invokeObjectMethod } from "../src/stdlib/object.js"
|
||||
|
||||
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
|
||||
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
|
||||
@@ -824,6 +826,174 @@ describe("stdlib integration", () => {
|
||||
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
|
||||
})
|
||||
|
||||
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperty({}, IteratorSymbol, {
|
||||
get() {
|
||||
reads.push(true)
|
||||
return target
|
||||
},
|
||||
})
|
||||
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
|
||||
expect(reads).toEqual([])
|
||||
expect(target).toEqual({ nested })
|
||||
})
|
||||
|
||||
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
|
||||
const target = {}
|
||||
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(Object.hasOwn(target, "nested")).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign cycle checks traverse sparse keys lazily", () => {
|
||||
const target = {}
|
||||
const reads: Array<boolean> = []
|
||||
const nested = Object.defineProperties([], {
|
||||
4294967294: { enumerable: true, value: target },
|
||||
later: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign result contains a circular value.",
|
||||
)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign stops after a supported symbol write fails", () => {
|
||||
const previous = () => ({ done: true })
|
||||
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
|
||||
const reads: Array<boolean> = []
|
||||
const source = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
|
||||
[AsyncIteratorSymbol]: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return () => ({ done: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
|
||||
"Object.assign could not assign property",
|
||||
)
|
||||
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
|
||||
expect(reads).toEqual([])
|
||||
})
|
||||
|
||||
test("Object.assign rejects direct and nested cycles", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
try { Object.assign(target, { self: target }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = { kept: true }
|
||||
const nested = { target }
|
||||
try { Object.assign(target, { nested }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ kept: true })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = target
|
||||
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const nested = {}
|
||||
nested[Symbol.iterator] = target
|
||||
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
|
||||
return true
|
||||
`),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("Object.assign preserves mutations before a circular field", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
|
||||
return null
|
||||
`),
|
||||
).toEqual({ before: 1 })
|
||||
expect(
|
||||
await value(`
|
||||
const target = {}
|
||||
const marker = {}
|
||||
const source = {}
|
||||
source[Symbol.iterator] = marker
|
||||
source[Symbol.asyncIterator] = target
|
||||
try { Object.assign(target, source) } catch {
|
||||
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
|
||||
}
|
||||
return null
|
||||
`),
|
||||
).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shared = { count: 1 }
|
||||
const target = {}
|
||||
const result = Object.assign(target, { left: shared, right: shared })
|
||||
result.left.count = 2
|
||||
return [result === target, result.left === shared, result.left === result.right, shared.count]
|
||||
`),
|
||||
).toEqual([true, true, true, 2])
|
||||
})
|
||||
|
||||
test("Object.assign traverses shared aliases once", () => {
|
||||
const reads: Array<boolean> = []
|
||||
const shared = Object.defineProperty({}, "value", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads.push(true)
|
||||
return 1
|
||||
},
|
||||
})
|
||||
const target = {}
|
||||
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
|
||||
target,
|
||||
)
|
||||
expect(target).toEqual({ left: shared, right: shared })
|
||||
expect(reads).toEqual([true])
|
||||
})
|
||||
|
||||
test("assignment resolves and reads its left side before evaluating the right side", async () => {
|
||||
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
|
||||
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
|
||||
|
||||
@@ -119,7 +119,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
name: model.canonical ?? model.providerID,
|
||||
...(model.settings ?? {}),
|
||||
headers: model.headers,
|
||||
body: projected.body,
|
||||
@@ -249,6 +249,7 @@ export const locationLayer = Layer.effect(
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = cacheKey({
|
||||
providerID: model.providerID,
|
||||
canonical: model.canonical,
|
||||
id: model.id,
|
||||
modelID: model.modelID,
|
||||
package: model.package,
|
||||
@@ -269,6 +270,7 @@ export const locationLayer = Layer.effect(
|
||||
const options = prepareOptions(model, packageName)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
canonical: model.canonical,
|
||||
package: packageName,
|
||||
settings: model.settings,
|
||||
headers: model.headers,
|
||||
@@ -301,10 +303,11 @@ export const locationLayer = Layer.effect(
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
const optionKey = providerOptionKey(packageName, providerID)
|
||||
const route: AnyRoute = {
|
||||
id: `ai-sdk:${packageName}`,
|
||||
provider: ProviderID.make(info.providerID),
|
||||
provider: ProviderID.make(providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
protocol: "ai-sdk",
|
||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||
@@ -331,13 +334,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) =>
|
||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
}
|
||||
return LanguageModel.make({
|
||||
id: info.modelID ?? info.id,
|
||||
provider: info.providerID,
|
||||
provider: providerID,
|
||||
route,
|
||||
compatibility: info.compatibility,
|
||||
})
|
||||
|
||||
@@ -74,6 +74,7 @@ const layer = Layer.effect(
|
||||
const projectModel = (model: Model.Info, provider: Provider.Info) => {
|
||||
return {
|
||||
...model,
|
||||
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider.package,
|
||||
settings: Provider.mergeOverlay(provider.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider.headers, model.headers),
|
||||
|
||||
@@ -2,21 +2,34 @@ export * as CodeModeCatalog from "./catalog.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.Literal("tool"),
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export type Namespace = {
|
||||
readonly type: "namespace"
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly tools: ReadonlyArray<Tool | Namespace>
|
||||
}
|
||||
|
||||
export type Inventory = {
|
||||
readonly tools: ReadonlyArray<Tool | Namespace>
|
||||
}
|
||||
|
||||
const Listing = Schema.Struct({
|
||||
path: Schema.String,
|
||||
line: Schema.String,
|
||||
})
|
||||
|
||||
const Namespace = Schema.Struct({
|
||||
const NamespaceSummary = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optionalKey(Schema.String),
|
||||
count: Schema.Number,
|
||||
entries: Schema.Array(Listing),
|
||||
})
|
||||
@@ -24,24 +37,31 @@ const Namespace = Schema.Struct({
|
||||
export const Summary = Schema.Struct({
|
||||
total: Schema.Number,
|
||||
shown: Schema.Number,
|
||||
namespaces: Schema.Array(Namespace),
|
||||
namespaces: Schema.Array(NamespaceSummary),
|
||||
})
|
||||
export type Summary = typeof Summary.Type
|
||||
|
||||
export type Options = {
|
||||
readonly budget?: number
|
||||
}
|
||||
|
||||
const DESCRIPTION_LIMIT = 120
|
||||
const CHARACTERS_PER_TOKEN = 4
|
||||
const INLINE_BUDGET = 2_000
|
||||
|
||||
// Keep every namespace searchable, then select full listings one per namespace per round,
|
||||
// Keep every namespace visible, then select full listings one per namespace per round,
|
||||
// considering shorter listings first until the inline budget is exhausted.
|
||||
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
|
||||
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
|
||||
export function summarize(inventory: Inventory, options: Options = {}): Summary {
|
||||
const budget = options.budget ?? INLINE_BUDGET
|
||||
const flattened = flatten(inventory.tools)
|
||||
const namespaces = [...Map.groupBy(flattened.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
|
||||
.sort(([left], [right]) => {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
})
|
||||
.map(([name, namespaceEntries]) => {
|
||||
const description = flattened.namespaces.get(name)?.description
|
||||
const listings = namespaceEntries
|
||||
.map((entry) => {
|
||||
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
|
||||
@@ -64,6 +84,7 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
)
|
||||
return {
|
||||
name,
|
||||
...(description === undefined ? {} : { description }),
|
||||
listings,
|
||||
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
|
||||
selectedListings: pinned,
|
||||
@@ -72,11 +93,25 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
|
||||
let remaining =
|
||||
budget -
|
||||
namespaces.reduce(
|
||||
(total, namespace) =>
|
||||
total +
|
||||
cost(
|
||||
namespaceLine({
|
||||
name: namespace.name,
|
||||
...(namespace.description === undefined ? {} : { description: namespace.description }),
|
||||
count: namespace.listings.length,
|
||||
entries: [],
|
||||
}),
|
||||
),
|
||||
0,
|
||||
) -
|
||||
namespaces
|
||||
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
|
||||
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
|
||||
.reduce((total, listing) => total + cost(listing.line), 0)
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectionIndex]
|
||||
@@ -93,19 +128,54 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
|
||||
const namespaceSummaries = namespaces.map((namespace) => ({
|
||||
name: namespace.name,
|
||||
...(namespace.description === undefined ? {} : { description: namespace.description }),
|
||||
count: namespace.listings.length,
|
||||
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
|
||||
}))
|
||||
return {
|
||||
total: entries.length,
|
||||
total: flattened.tools.length,
|
||||
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
|
||||
namespaces: namespaceSummaries,
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(entries: ReadonlyArray<Tool | Namespace>, path: ReadonlyArray<string> = []) {
|
||||
const tools: Array<Omit<Tool, "name"> & { readonly path: string }> = []
|
||||
const namespaces = new Map<string, Namespace>()
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "tool") {
|
||||
tools.push({
|
||||
type: "tool",
|
||||
path: [...path, entry.name].join("."),
|
||||
description: entry.description,
|
||||
signature: entry.signature,
|
||||
...(entry.pinned === undefined ? {} : { pinned: entry.pinned }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const next = [...path, entry.name]
|
||||
namespaces.set(next.join("."), entry)
|
||||
const nested = flatten(entry.tools, next)
|
||||
tools.push(...nested.tools)
|
||||
for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace)
|
||||
}
|
||||
return { tools, namespaces }
|
||||
}
|
||||
|
||||
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
|
||||
}
|
||||
|
||||
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return listings
|
||||
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
|
||||
.map((listing) => ({ listing, cost: cost(listing.line) }))
|
||||
.toSorted((left, right) => {
|
||||
if (left.cost !== right.cost) return left.cost - right.cost
|
||||
if (left.listing.path < right.listing.path) return -1
|
||||
@@ -113,3 +183,7 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function cost(text: string) {
|
||||
return Math.round(text.length / CHARACTERS_PER_TOKEN)
|
||||
}
|
||||
|
||||
@@ -23,14 +23,7 @@ export function render(catalog: CodeModeCatalog.Summary) {
|
||||
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
const label =
|
||||
namespace.entries.length === namespace.count
|
||||
? count
|
||||
: namespace.entries.length === 0
|
||||
? `${count}, none shown`
|
||||
: `${count}, ${namespace.entries.length} shown`
|
||||
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
|
||||
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
|
||||
})
|
||||
|
||||
return `${prompt(catalog.shown < catalog.total)}
|
||||
@@ -47,6 +40,15 @@ ${render(current)}`
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return replacement
|
||||
|
||||
const descriptions = Instructions.diffByKey(
|
||||
previous.namespaces.filter((namespace) => namespace.description !== undefined),
|
||||
current.namespaces.filter((namespace) => namespace.description !== undefined),
|
||||
(namespace) => namespace.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
|
||||
return replacement
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
current.namespaces.flatMap((namespace) => namespace.entries),
|
||||
@@ -126,8 +128,8 @@ ${render(current)}`
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
|
||||
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
|
||||
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
|
||||
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
export * as CodeModeTool from "./tool.js"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
|
||||
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type {
|
||||
Content,
|
||||
Context,
|
||||
Error,
|
||||
Info,
|
||||
Metadata,
|
||||
Namespace as ToolNamespace,
|
||||
Result,
|
||||
} from "@opencode-ai/schema/tool"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -31,6 +40,23 @@ type CollectedFiles = {
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
type Node<T> = {
|
||||
tool?: T
|
||||
namespace?: ToolNamespace
|
||||
readonly children: Map<string, Node<T>>
|
||||
}
|
||||
|
||||
type ToolNode = Node<Tool.Tool<never>>
|
||||
|
||||
type Tools = {
|
||||
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
|
||||
}
|
||||
|
||||
export type Inventory = {
|
||||
readonly tools: ReadonlyMap<string, Info>
|
||||
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
@@ -42,7 +68,7 @@ const description = [
|
||||
].join("\n")
|
||||
|
||||
export const create = (
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
|
||||
) => {
|
||||
return {
|
||||
@@ -61,7 +87,7 @@ export const create = (
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
inventory,
|
||||
(name, tool, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
@@ -132,36 +158,124 @@ export const create = (
|
||||
} satisfies Info
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
export const catalog = (inventory: Inventory) => {
|
||||
const pinned = new Set(
|
||||
Array.from(registrations.values())
|
||||
Array.from(inventory.tools.values())
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
|
||||
const root: CatalogNode = { children: new Map() }
|
||||
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
|
||||
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
|
||||
getNode(root, tool.path).tool = {
|
||||
type: "tool",
|
||||
name: tool.path.split(".").at(-1) ?? tool.path,
|
||||
description: tool.description,
|
||||
signature: tool.signature,
|
||||
pinned: pinned.has(tool.path),
|
||||
}
|
||||
return {
|
||||
tools: renderCatalog(root),
|
||||
} satisfies CodeModeCatalog.Inventory
|
||||
}
|
||||
|
||||
type CatalogNode = Node<CodeModeCatalog.Tool>
|
||||
|
||||
function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool | CodeModeCatalog.Namespace> {
|
||||
return Array.from(root.children)
|
||||
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.flatMap(([name, node]) => {
|
||||
const tools = renderCatalog(node)
|
||||
const namespace =
|
||||
node.namespace === undefined && tools.length === 0
|
||||
? undefined
|
||||
: {
|
||||
type: "namespace" as const,
|
||||
name,
|
||||
...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }),
|
||||
tools,
|
||||
}
|
||||
if (node.tool === undefined) return namespace === undefined ? [] : [namespace]
|
||||
if (namespace === undefined) return [node.tool]
|
||||
return [node.tool, namespace]
|
||||
})
|
||||
}
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
// A path may carry namespace metadata, a callable tool, child tools, or all three.
|
||||
const root: ToolNode = { children: new Map() }
|
||||
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
|
||||
for (const [name, registration] of inventory.tools) {
|
||||
const child = definition(registration)
|
||||
const path = qualifiedName(registration)
|
||||
tools[path] = Tool.make({
|
||||
getNode(root, qualifiedName(registration)).tool = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema ?? Schema.NullOr(Schema.String),
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
return path.split(".").reduce((parent, name) => {
|
||||
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
|
||||
parent.children.set(name, child)
|
||||
return child
|
||||
}, root)
|
||||
}
|
||||
|
||||
function renderTools(root: ToolNode) {
|
||||
const callables = new Map<string, Tool.Tool<never>>()
|
||||
const tools = renderChildren(root, [], callables)
|
||||
for (const [path, tool] of callables) tools[path] = tool
|
||||
return tools
|
||||
}
|
||||
|
||||
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
|
||||
return Object.fromEntries(
|
||||
Array.from(node.children).flatMap(([name, child]) => {
|
||||
const next = [...path, name]
|
||||
// A record cannot hold both a top-level tool and namespace under the same key.
|
||||
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
|
||||
const tools: Tools = {}
|
||||
flattenTools(child, next, tools)
|
||||
return Object.entries(tools)
|
||||
}
|
||||
return [[name, renderEntry(child, next, callables)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderEntry(
|
||||
node: ToolNode,
|
||||
path: ReadonlyArray<string>,
|
||||
callables: Map<string, Tool.Tool<never>>,
|
||||
): Tools[string] {
|
||||
const tools = renderChildren(node, path, callables)
|
||||
// CodeMode merges this dotted tool path with the nested namespace entry.
|
||||
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
|
||||
callables.set(path.join("."), node.tool)
|
||||
if (node.namespace !== undefined)
|
||||
return Namespace.make({
|
||||
description: node.namespace.description,
|
||||
tools,
|
||||
})
|
||||
if (node.tool === undefined) return tools
|
||||
if (node.children.size === 0) return node.tool
|
||||
return tools
|
||||
}
|
||||
|
||||
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
|
||||
if (node.tool !== undefined) tools[path.join(".")] = node.tool
|
||||
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = normalizedName(registration)
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
|
||||
@@ -44,8 +44,17 @@ export const Plugin = define({
|
||||
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
|
||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||
const providerID = id
|
||||
const current = catalog.provider.get(providerID)
|
||||
const source = catalog.provider.get(item.canonical ?? current?.provider.canonical ?? providerID)
|
||||
const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (changed && source && source.provider !== provider)
|
||||
Object.assign(provider, structuredClone(source.provider), {
|
||||
id: provider.id,
|
||||
integrationID: provider.integrationID,
|
||||
})
|
||||
provider.activation = "enabled"
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
@@ -53,7 +62,14 @@ export const Plugin = define({
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
|
||||
const inherit = changed || !catalog.model.get(providerID, id)
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
if (inherit && base) {
|
||||
Object.assign(model, structuredClone(base))
|
||||
if (item.package !== undefined) model.package = undefined
|
||||
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
||||
}
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Bus } from "../bus.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { Location } from "../location.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
|
||||
import { Watcher } from "./watcher.js"
|
||||
|
||||
@@ -24,7 +24,7 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
bus.publish(FileSystem.Event.Changed, {
|
||||
@@ -93,7 +93,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
yield* reconcile(policy.current())
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
@@ -109,13 +109,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
Watcher.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
Git.node,
|
||||
Bus.node,
|
||||
PluginSupervisor.node,
|
||||
LocationWatcherPolicy.node,
|
||||
],
|
||||
deps: [Watcher.node, FSUtil.node, Location.node, Git.node, Bus.node, Plugin.node, LocationWatcherPolicy.node],
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
import { SessionInstructions } from "./session/instructions.js"
|
||||
import { SessionGenerateNode } from "./session/generate-node.js"
|
||||
import { SessionPromptNode } from "./session/prompt-node.js"
|
||||
import { SessionRevertNode } from "./session/revert-node.js"
|
||||
import { SessionPrompt } from "./session/prompt.js"
|
||||
import { SessionRevert } from "./session/revert.js"
|
||||
import { McpTool } from "./tool/mcp.js"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
@@ -97,8 +97,8 @@ const nodes = [
|
||||
Form.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
SessionPromptNode.node,
|
||||
SessionRevertNode.node,
|
||||
SessionPrompt.node,
|
||||
SessionRevert.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
@@ -81,7 +80,6 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const markers = yield* ProjectMarkers.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = resolvePath(location.directory, input.path)
|
||||
@@ -113,7 +111,7 @@ const layer = Layer.effect(
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join(
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
@@ -128,5 +126,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ export { Info, Ref, response }
|
||||
|
||||
export interface Interface extends Info {
|
||||
readonly vcs?: Project.Vcs
|
||||
readonly vcsBackend?: string
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
@@ -28,7 +27,6 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
|
||||
vcs: resolved.vcs,
|
||||
vcsBackend: resolved.vcsBackend,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -132,7 +132,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
packageName,
|
||||
settings: configured,
|
||||
modelID: resolved.modelID ?? resolved.id,
|
||||
providerID: resolved.providerID,
|
||||
providerID: resolved.canonical ?? resolved.providerID,
|
||||
})
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
@@ -161,6 +161,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
@@ -169,7 +170,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
provider: resolved.canonical ?? resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
|
||||
+99
-111
@@ -4,42 +4,26 @@ export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app.js"
|
||||
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
import { Skill } from "./skill.js"
|
||||
import { State } from "./state.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Permission } from "./permission.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Failure[],
|
||||
) => Effect.Effect<void>
|
||||
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
|
||||
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
|
||||
readonly awaitActivation: Effect.Effect<void>
|
||||
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
|
||||
readonly hold: () => Effect.Effect<Effect.Effect<void>>
|
||||
}
|
||||
|
||||
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
export type Generation = PluginDefinition & {
|
||||
readonly revision: string
|
||||
readonly source?: Plugin.Source
|
||||
readonly features?: Plugin.Features
|
||||
}
|
||||
@@ -52,11 +36,22 @@ 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: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
const hold = () =>
|
||||
Effect.sync(() => {
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
})
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
const load = Effect.fnUntraced(function* (plugin: Generation) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
@@ -83,7 +78,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Versioned[],
|
||||
plugins: readonly Generation[],
|
||||
failures: readonly Failure[] = [],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
@@ -93,78 +88,92 @@ const layer = Layer.effect(
|
||||
ids.add(definition.id)
|
||||
}
|
||||
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
active.size === definitions.length &&
|
||||
Array.from(active.values()).every((entry, index) => {
|
||||
const definition = definitions[index]
|
||||
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
|
||||
})
|
||||
) {
|
||||
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
yield* Effect.acquireUseRelease(
|
||||
hold(),
|
||||
() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
if (
|
||||
active.size === definitions.length &&
|
||||
Array.from(active.values()).every((entry, index) => {
|
||||
const definition = definitions[index]
|
||||
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
) {
|
||||
for (const definition of definitions) {
|
||||
const entry = active.get(definition.id)
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
"plugin.id": definition.id,
|
||||
})
|
||||
const nextInventory = [...definitions.map(activeInfo), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
return
|
||||
}
|
||||
|
||||
const removed = Array.from(active.entries())
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
})
|
||||
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
"plugin.id": definition.id,
|
||||
})
|
||||
}
|
||||
|
||||
const removed = Array.from(active.entries())
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
(release) => release,
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit), { flush: false })
|
||||
}),
|
||||
)
|
||||
const close = (exit: Exit.Exit<unknown, unknown>) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit), { flush: false })
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(close)
|
||||
|
||||
const service = Service.of({
|
||||
activate,
|
||||
close,
|
||||
awaitActivation: ready.await,
|
||||
hold,
|
||||
list: Effect.fn("Plugin.list")(function* () {
|
||||
return inventory
|
||||
}),
|
||||
@@ -174,7 +183,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
function activeInfo(plugin: Generation): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
@@ -186,26 +195,5 @@ function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
Bus.node,
|
||||
App.node,
|
||||
Agent.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Mcp.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Rpc.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
WebSearch.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
],
|
||||
deps: [PluginHost.requirements],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/i
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import type { Event } from "@opencode-ai/schema/event"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -31,7 +32,9 @@ import { WebSearch } from "../websearch.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
import type { Interface } from "../plugin.js"
|
||||
import { Service, type Interface } from "../plugin.js"
|
||||
import { SessionGenerate } from "../session/generate.js"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
type RpcEvent = Event.Payload & {
|
||||
@@ -40,7 +43,10 @@ type RpcEvent = Event.Payload & {
|
||||
readonly data: Readonly<Record<string, unknown>>
|
||||
}
|
||||
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
|
||||
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
|
||||
export const make = Effect.fn("PluginHost.make")(function* (
|
||||
plugin: Pick<Interface, "list">,
|
||||
pluginID: string = "test",
|
||||
) {
|
||||
const app = yield* App.Metadata
|
||||
const agents = yield* Agent.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
@@ -61,6 +67,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
const permission = yield* Permission.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const route = <A, E, R>(sessionID: Session.ID, effect: Effect.Effect<A, E, R>) =>
|
||||
runtime.session.get(sessionID).pipe(Effect.flatMap((session) => runtime.instances.provide(session)(effect)))
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
@@ -439,8 +447,28 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
switchAgent: runtime.session.switchAgent,
|
||||
switchModel: runtime.session.switchModel,
|
||||
prompt: runtime.session.prompt,
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
generate: (input) =>
|
||||
route(
|
||||
input.sessionID,
|
||||
SessionGenerate.Service.use((generate) => generate.generate(input)),
|
||||
).pipe(Effect.map((text) => ({ text }))),
|
||||
command: (input) =>
|
||||
route(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Service
|
||||
yield* plugins.awaitActivation
|
||||
const commands = yield* Command.Service
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.sessionID,
|
||||
prompt: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
delivery: input.delivery ?? "steer",
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
rename: runtime.session.rename,
|
||||
move: runtime.session.move,
|
||||
synthetic: runtime.session.synthetic,
|
||||
@@ -455,6 +483,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
return context
|
||||
})
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
App.node,
|
||||
Agent.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Bus.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
Mcp.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Rpc.node,
|
||||
Skill.node,
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
PluginRuntime.node,
|
||||
])
|
||||
|
||||
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
|
||||
const namespace = `plugin:${pluginID
|
||||
.split("")
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Context, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
|
||||
/**
|
||||
* Holds the plugins one instance is born with. Unlike the host-global
|
||||
@@ -13,16 +13,13 @@ import type { Versioned } from "../plugin.js"
|
||||
* for the instance's lifetime; runtime dynamism lives inside plugins through
|
||||
* the container transform/reload APIs.
|
||||
*
|
||||
* Limitations: `vcs` marker declarations in an instance list are not seen by
|
||||
* `ProjectMarkers` (it is global and runs during project resolution, before
|
||||
* the instance exists — unlike `SdkPlugins`, whose declarations it consumes
|
||||
* directly), and config plugin operations may disable instance plugins by id,
|
||||
* matching `SdkPlugins` behavior.
|
||||
* Config plugin operations may disable instance plugins by id, matching
|
||||
* `SdkPlugins` behavior.
|
||||
*/
|
||||
export type List = readonly Plugin[]
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => readonly Versioned[]
|
||||
readonly all: () => readonly Generation[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
|
||||
@@ -33,8 +30,8 @@ export const node = makeLocationNode({
|
||||
deps: [],
|
||||
})
|
||||
|
||||
// The constant version is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, version) pair as the same plugin across activations, which
|
||||
// The constant revision is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, revision) pair as the same plugin across activations, which
|
||||
// is only correct because a bound list never changes after creation.
|
||||
// `source: "sdk"` means host-contributed; an instance list is the
|
||||
// per-instance form of the same channel.
|
||||
@@ -43,8 +40,6 @@ export function bound(plugins: List) {
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
|
||||
}
|
||||
const stamped = plugins.map(
|
||||
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
|
||||
)
|
||||
const stamped = plugins.map((plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }))
|
||||
return Layer.succeed(Service, Service.of({ all: () => stamped }))
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
|
||||
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
|
||||
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
@@ -90,100 +89,52 @@ import { VcsGitPlugin } from "./vcs/git.js"
|
||||
import { WarmingPlugin } from "./warming.js"
|
||||
import { WellKnownPlugin } from "../wellknown/plugin.js"
|
||||
|
||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const command = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const credential = yield* Credential.Service
|
||||
const pluginSources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const image = yield* Image.Service
|
||||
const instructionDiscovery = yield* InstructionDiscovery.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const form = yield* Form.Service
|
||||
const read = yield* ReadToolFileSystem.Service
|
||||
const reference = yield* Reference.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const instructions = yield* SessionInstructions.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const skill = yield* Skill.Service
|
||||
const skillDiscovery = yield* SkillDiscovery.Service
|
||||
const tools = yield* Tool.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
return Context.mergeAll(
|
||||
Context.make(Agent.Service, agent),
|
||||
Context.make(AppProcess.Service, processes),
|
||||
Context.make(Catalog.Service, catalog),
|
||||
Context.make(Command.Service, command),
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Credential.Service, credential),
|
||||
Context.make(ConfigPluginSource.Service, pluginSources),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
Context.make(HttpClient.HttpClient, http),
|
||||
Context.make(Image.Service, image),
|
||||
Context.make(InstructionDiscovery.Service, instructionDiscovery),
|
||||
Context.make(Integration.Service, integration),
|
||||
Context.make(KV.Service, kv),
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(Mcp.Service, mcp),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
Context.make(PluginRuntime.Service, runtime),
|
||||
Context.make(Form.Service, form),
|
||||
Context.make(ReadToolFileSystem.Service, read),
|
||||
Context.make(Reference.Service, reference),
|
||||
Context.make(WebSearch.Service, websearch),
|
||||
Context.make(Ripgrep.Service, ripgrep),
|
||||
Context.make(SessionCompaction.Service, compaction),
|
||||
Context.make(SessionInstructions.Service, instructions),
|
||||
Context.make(Shell.Service, shell),
|
||||
Context.make(ShellSelect.Service, shellSelect),
|
||||
Context.make(Snapshot.Service, snapshot),
|
||||
Context.make(Skill.Service, skill),
|
||||
Context.make(SkillDiscovery.Service, skillDiscovery),
|
||||
Context.make(Tool.Service, tools),
|
||||
Context.make(ToolOutput.Service, toolOutput),
|
||||
Context.make(Watcher.Service, watcher),
|
||||
Context.make(WellKnown.Service, wellknown),
|
||||
)
|
||||
})
|
||||
const services = [
|
||||
Agent.Service,
|
||||
AppProcess.Service,
|
||||
Catalog.Service,
|
||||
Command.Service,
|
||||
Config.Service,
|
||||
Credential.Service,
|
||||
Bus.Service,
|
||||
Environment.Service,
|
||||
FileMutation.Service,
|
||||
Formatter.Service,
|
||||
LocationWatcherPolicy.Service,
|
||||
FileSystem.Service,
|
||||
FSUtil.Service,
|
||||
Global.Service,
|
||||
HttpClient.HttpClient,
|
||||
Image.Service,
|
||||
InstructionDiscovery.Service,
|
||||
Integration.Service,
|
||||
KV.Service,
|
||||
Location.Service,
|
||||
LocationMutation.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
Npm.Service,
|
||||
Permission.Service,
|
||||
PluginRuntime.Service,
|
||||
Form.Service,
|
||||
ReadToolFileSystem.Service,
|
||||
Reference.Service,
|
||||
WebSearch.Service,
|
||||
Ripgrep.Service,
|
||||
SessionCompaction.Service,
|
||||
SessionInstructions.Service,
|
||||
Shell.Service,
|
||||
ShellSelect.Service,
|
||||
Snapshot.Service,
|
||||
Skill.Service,
|
||||
SkillDiscovery.Service,
|
||||
Tool.Service,
|
||||
ToolOutput.Service,
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
] as const
|
||||
|
||||
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
|
||||
|
||||
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
@@ -192,7 +143,6 @@ export const requirements = LayerNode.group([
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
@@ -282,12 +232,12 @@ const post = [
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
const context = yield* services()
|
||||
// Capture only services; activation supplies the child Scope and batching context.
|
||||
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
|
||||
const resolve = (plugins: readonly InternalPlugin[]) =>
|
||||
plugins.map(
|
||||
(plugin): Plugin => ({
|
||||
id: plugin.id,
|
||||
vcs: plugin.vcs,
|
||||
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,23 +8,17 @@ import { readdir } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import { PluginPromise } from "./promise.js"
|
||||
|
||||
const Discovery = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
markers: Schema.Array(Schema.String),
|
||||
})
|
||||
const Definition = Schema.Struct({
|
||||
const Module = Schema.Struct({
|
||||
default: Schema.Union([
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
vcs: Schema.optional(Discovery),
|
||||
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
vcs: Schema.optional(Discovery),
|
||||
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
|
||||
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
|
||||
),
|
||||
@@ -32,22 +26,39 @@ const Definition = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed = local
|
||||
? { entrypoint: pathToFileURL(operation.target).href }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const installed: Npm.EntryPoint = local
|
||||
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
|
||||
: options?.install === false
|
||||
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
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 source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
|
||||
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new LoadError({
|
||||
message: "Plugin must export a default definition with an id and an effect or setup function.",
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
const features = local
|
||||
? yield* localFeatures(operation.target)
|
||||
@@ -63,24 +74,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
return {
|
||||
id: plugin.id,
|
||||
features,
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify(operation),
|
||||
revision: JSON.stringify([operation, installed.revision]),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
: { type: "package" as const, package: operation.target },
|
||||
: {
|
||||
type: "package" as const,
|
||||
target: operation.target,
|
||||
...(installed.version ? { version: installed.version } : {}),
|
||||
},
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Versioned
|
||||
} 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 names = new Set(
|
||||
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
|
||||
)
|
||||
const has = (name: string) =>
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
|
||||
names.has(`${name}.${extension}`),
|
||||
)
|
||||
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
|
||||
return {
|
||||
...(has("tui") ? { tui: true as const } : {}),
|
||||
...(has("rpc") ? { rpc: true as const } : {}),
|
||||
|
||||
@@ -6,17 +6,14 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { ConfigProviderV1 } from "../../v1/config/provider.js"
|
||||
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
|
||||
import { ConfigV1 } from "../../v1/config/config.js"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
@@ -89,7 +86,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let connected = false
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
let providers: typeof RemoteResponse.Type.providers | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
@@ -117,59 +114,73 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
yield* load()
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const [providerID, item] of Object.entries(providers ?? {})) {
|
||||
const source = catalog.provider.get(item.canonical ?? providerID)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (source && source.provider !== provider)
|
||||
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
|
||||
provider.integrationID = Integration.ID.make("opencode")
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...withoutCredentials(item.options),
|
||||
...(item.api ? { baseURL: item.api } : {}),
|
||||
}
|
||||
provider.headers = { ...provider.headers, ...item.options?.headers }
|
||||
provider.package = item.package ?? provider.package
|
||||
provider.settings = Provider.mergeOverlay(
|
||||
withoutCredentials(provider.settings),
|
||||
withoutCredentials(item.settings),
|
||||
)
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
})
|
||||
|
||||
for (const [modelID, config] of Object.entries(item.models ?? {})) {
|
||||
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
|
||||
catalog.model.update(providerID, modelID, (model) => {
|
||||
if (config.family !== undefined) model.family = Model.Family.make(config.family)
|
||||
Object.assign(model, structuredClone(base ?? model))
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
|
||||
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
|
||||
if (config.provider !== undefined) {
|
||||
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
|
||||
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
|
||||
}
|
||||
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
|
||||
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
|
||||
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
|
||||
model.headers = { ...model.headers, ...config.headers }
|
||||
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const [id, options] of Object.entries(config.variants)) {
|
||||
const variantID = Model.VariantID.make(id)
|
||||
let existing = model.variants.find((item) => item.id === variantID)
|
||||
if (!existing) {
|
||||
existing = { id: variantID }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
existing.headers = { ...existing.headers, ...options.headers }
|
||||
existing.settings = {
|
||||
...existing.settings,
|
||||
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
|
||||
}
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
|
||||
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
||||
if (config.capabilities !== undefined)
|
||||
model.capabilities = {
|
||||
...config.capabilities,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
model.settings = Provider.mergeOverlay(
|
||||
withoutCredentials(model.settings),
|
||||
withoutCredentials(config.settings),
|
||||
)
|
||||
model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
for (const variant of config.variants ?? []) {
|
||||
let existing = model.variants.find((item) => item.id === variant.id)
|
||||
if (!existing) {
|
||||
existing = { id: variant.id }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
if (variant.settings !== undefined)
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined)
|
||||
existing.body = Provider.mergeOverlay(
|
||||
existing.body,
|
||||
variantBody(variant.body, model.package ?? item.package ?? source?.provider.package),
|
||||
)
|
||||
}
|
||||
if (config.release_date !== undefined) {
|
||||
const released = Date.parse(config.release_date)
|
||||
model.time.released = Number.isFinite(released) ? released : 0
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
model.cost = remoteCost(config.cost)
|
||||
}
|
||||
model.status = config.status ?? "active"
|
||||
model.enabled = config.status !== "deprecated"
|
||||
if (config.limit !== undefined) model.limit = { ...config.limit }
|
||||
if (config.cost !== undefined)
|
||||
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
|
||||
tier: cost.tier && { ...cost.tier },
|
||||
input: cost.input,
|
||||
output: cost.output,
|
||||
cache: {
|
||||
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
|
||||
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
}))
|
||||
model.enabled = !config.disabled
|
||||
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -208,7 +219,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const token = value.type === "oauth" ? value.access : value.key
|
||||
return http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${server}/api/config`).pipe(
|
||||
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
@@ -219,14 +230,29 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
if (response.status === 404) return Effect.undefined
|
||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
Effect.map((remote) => remote.config.provider),
|
||||
Effect.map((remote) => remote.providers),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
|
||||
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
|
||||
function variantBody(body: Readonly<Record<string, unknown>>, packageName: string | undefined) {
|
||||
if (packageName !== Provider.aisdk("@ai-sdk/openai")) return body
|
||||
const { reasoningEffort, reasoningSummary, ...native } = body
|
||||
const reasoning = {
|
||||
...(typeof reasoningEffort === "string" ? { effort: reasoningEffort } : {}),
|
||||
...(typeof reasoningSummary === "string" ? { summary: reasoningSummary } : {}),
|
||||
}
|
||||
if (Object.keys(reasoning).length === 0) return body
|
||||
// Existing Console variants stored SDK options here before V2 consumed raw bodies.
|
||||
return Provider.mergeOverlay({ reasoning }, native)
|
||||
}
|
||||
|
||||
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
|
||||
return (
|
||||
body &&
|
||||
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeServer(input: unknown) {
|
||||
@@ -242,30 +268,6 @@ function normalizeServer(input: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
|
||||
const base = {
|
||||
input: Money.USDPerMillionTokens.make(input.input),
|
||||
output: Money.USDPerMillionTokens.make(input.output),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
|
||||
},
|
||||
}
|
||||
if (!input.context_over_200k) return [base]
|
||||
return [
|
||||
base,
|
||||
{
|
||||
tier: { type: "context" as const, size: 200_000 },
|
||||
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
|
||||
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
|
||||
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
|
||||
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export * as PluginRuntimeProvider from "./runtime-provider.js"
|
||||
|
||||
import { Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Job } from "../job.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
|
||||
// Application wiring stays outside the facade so Plugin can be imported by Session.
|
||||
export const configured = (cell: PluginRuntime.Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const instances = yield* Instance.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: PluginRuntime.Interface = {
|
||||
session: sessions,
|
||||
instances,
|
||||
job: jobs,
|
||||
persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* Agent.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* agents.list(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* mcp.servers(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
}
|
||||
cell.runtime = runtime
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (cell.runtime === runtime) cell.runtime = undefined
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
deps: [
|
||||
PluginRuntime.node,
|
||||
Session.node,
|
||||
Instance.byLocationNode,
|
||||
Job.node,
|
||||
LocationServiceMap.node,
|
||||
PersistentPty.node,
|
||||
],
|
||||
})
|
||||
|
||||
export const node = configured(PluginRuntime.defaultCell)
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as PluginRuntime from "./runtime.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import type { Agent } from "../agent.js"
|
||||
import type { Instance } from "../instance/service.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Session } from "../session.js"
|
||||
import type { Job } from "../job.js"
|
||||
import type { Location } from "../location.js"
|
||||
import type { Mcp } from "../mcp/index.js"
|
||||
import type { PersistentPty } from "../persistent-pty.js"
|
||||
import type { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
@@ -17,8 +17,6 @@ export interface Interface {
|
||||
| "create"
|
||||
| "messages"
|
||||
| "prompt"
|
||||
| "generate"
|
||||
| "command"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "resume"
|
||||
@@ -29,6 +27,7 @@ export interface Interface {
|
||||
| "wait"
|
||||
| "context"
|
||||
>
|
||||
readonly instances: Pick<Instance.Interface, "provide">
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly persistentPty: Pick<PersistentPty.Interface, "read">
|
||||
readonly location: {
|
||||
@@ -60,7 +59,7 @@ const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A
|
||||
return f(runtime)
|
||||
})
|
||||
|
||||
const defaultCell = makeCell()
|
||||
export const defaultCell = makeCell()
|
||||
|
||||
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
|
||||
Layer.succeed(
|
||||
@@ -71,8 +70,6 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
|
||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
move: (input) => require(cell, (runtime) => runtime.session.move(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
@@ -83,6 +80,9 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
|
||||
},
|
||||
instances: {
|
||||
provide: (session) => (effect) => require(cell, (runtime) => runtime.instances.provide(session)(effect)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
||||
@@ -106,70 +106,6 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const providerLayerWithCell = (cell: Cell) =>
|
||||
Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const runtime: Interface = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
persistentPty,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* Agent.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* agents.list(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* mcp.servers(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
}
|
||||
cell.runtime = runtime
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (cell.runtime === runtime) cell.runtime = undefined
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
// Raw layer replacements are compiled without dependencies, so cell-scoped
|
||||
// provider replacements must go through this node to keep their deps wired.
|
||||
export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -4,15 +4,14 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
|
||||
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk` host) contributes,
|
||||
* so `PluginSupervisor` can add them on every Location boot through the ordinary
|
||||
* generation path that `PluginSupervisor` uses for plugins discovered from
|
||||
* config. Registration publishes an unlocated update so every booted Location
|
||||
* so the application loader can add them on every Location boot through its
|
||||
* ordinary generation path. Registration publishes an unlocated update so every booted Location
|
||||
* reloads its plugin generation from the shared store.
|
||||
*
|
||||
* Each host-global layer owns one private store. Location graphs reuse that
|
||||
@@ -21,7 +20,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
readonly all: () => readonly Versioned[]
|
||||
readonly all: () => readonly Generation[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
@@ -30,12 +29,12 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const plugins = new Map<string, Versioned>()
|
||||
const plugins = new Map<string, Generation>()
|
||||
let revision = 0
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
|
||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
export * as SkillPlugin from "./skill.js"
|
||||
|
||||
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { Config } from "../config.js"
|
||||
import os from "os"
|
||||
import opencodeContent from "./skill/opencode.md" with { type: "text" }
|
||||
import reportContent from "./skill/report.md" with { type: "text" }
|
||||
@@ -68,9 +69,11 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||
})
|
||||
|
||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
return (yield* sources.operations())
|
||||
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
|
||||
const config = yield* Config.Service
|
||||
return (yield* config.entries())
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.flatMap((entry) => entry.info.plugins ?? [])
|
||||
.map((entry) => (typeof entry === "string" ? entry : entry.package))
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
* imports: the supervisor reaches PluginRuntime, which depends on Session.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Stream } from "effect"
|
||||
import { Cause, Effect, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -13,18 +12,20 @@ import { InstancePlugins } from "./instance.js"
|
||||
import { PluginInternal } from "./internal.js"
|
||||
import { PluginModule } from "./module.js"
|
||||
import { SdkPlugins } from "./sdk.js"
|
||||
import { Service } from "./supervisor-service.js"
|
||||
import { PluginUpdate } from "./update.js"
|
||||
|
||||
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
pre: readonly Plugin.Generation[],
|
||||
post: readonly Plugin.Generation[],
|
||||
operations: readonly ConfigPluginSource.Operation[],
|
||||
install: boolean,
|
||||
) {
|
||||
const matches = (selector: string, target: string) =>
|
||||
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
|
||||
const definitions = [...pre, ...post]
|
||||
const enabled = new Set(definitions.map((plugin) => plugin.id))
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const packages = new Map<string, Plugin.Generation>()
|
||||
const pending = new Set<string>()
|
||||
const failures = new Map<
|
||||
string,
|
||||
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
@@ -51,17 +52,23 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
|
||||
Effect.as({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
),
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
|
||||
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if ("pending" in plugin) {
|
||||
pending.add(operation.target)
|
||||
continue
|
||||
}
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
state: { status: "failed", error: plugin.error },
|
||||
state: { status: "failed", error: plugin.error, ref: plugin.ref },
|
||||
features: { server: true },
|
||||
})
|
||||
continue
|
||||
@@ -80,81 +87,122 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
|
||||
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
|
||||
),
|
||||
pending: [...pending],
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const internal = yield* PluginInternal.list()
|
||||
let release: Effect.Effect<void> | undefined = yield* registry.hold()
|
||||
yield* Effect.addFinalizer(() => release ?? Effect.void)
|
||||
// Built-ins capture services from this layer; unload them before those services close.
|
||||
yield* Effect.addFinalizer(registry.close)
|
||||
let packages = new Set<string>()
|
||||
let outdated = new Set<string>()
|
||||
let generation = 0
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
const current = ++generation
|
||||
// Combine internal plugins with host-contributed plugins in boot order.
|
||||
// Instance-bound plugins come last: later activation can override earlier
|
||||
// container writes, so the instance's explicit choices win over globals.
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
...instance.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
version: "internal",
|
||||
revision: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
if (resolved.refreshes.length) {
|
||||
yield* Effect.forEach(
|
||||
resolved.refreshes,
|
||||
(target) =>
|
||||
npm
|
||||
.add(target, { subpaths: ["server", ""], refresh: true })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.forkDetach)
|
||||
}
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
|
||||
const apply = (resolved: typeof immediate) =>
|
||||
registry.activate(
|
||||
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
|
||||
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
|
||||
)
|
||||
yield* apply(immediate)
|
||||
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
|
||||
if (resolved !== immediate) yield* apply(resolved)
|
||||
const loaded = new Set(
|
||||
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
|
||||
plugin.source?.type === "package" ? [plugin.source.target] : [],
|
||||
),
|
||||
)
|
||||
packages = loaded
|
||||
yield* Effect.forEach(
|
||||
loaded,
|
||||
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.flatMap((checked) => {
|
||||
if (current !== generation) return Effect.void
|
||||
const next = new Set(checked.flatMap(([target, available]) => (available ? [target] : [])))
|
||||
if (next.size === outdated.size && [...next].every((target) => outdated.has(target))) return Effect.void
|
||||
outdated = next
|
||||
return apply(resolved)
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
const reloads = Stream.merge(
|
||||
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
|
||||
updates.changes().pipe(
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
yield* ready.close
|
||||
if (!release) release = yield* registry.hold()
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
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"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
if (observed === target) yield* ready.open
|
||||
if (observed !== target) return
|
||||
const settled = release
|
||||
release = undefined
|
||||
if (settled) yield* settled
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: ready.await })
|
||||
yield* Effect.sleep("24 hours").pipe(
|
||||
Effect.andThen(
|
||||
Effect.acquireUseRelease(
|
||||
registry.hold(),
|
||||
() => activate(),
|
||||
(release) => release,
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -163,6 +211,7 @@ const nodeDeps = [
|
||||
SdkPlugins.node,
|
||||
InstancePlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
PluginUpdate.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
@@ -170,7 +219,7 @@ const nodeDeps = [
|
||||
|
||||
function pluginSource(target: string): Plugin.Source {
|
||||
if (path.isAbsolute(target)) return { type: "local", path: target }
|
||||
return { type: "package", package: target }
|
||||
return { type: "package", target }
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
export const node = makeLocationNode({ name: "plugin-supervisor", layer, deps: nodeDeps })
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export * as PluginUpdate from "./update.js"
|
||||
|
||||
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
|
||||
const interval = 24 * 60 * 60 * 1_000
|
||||
|
||||
export interface Interface {
|
||||
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
|
||||
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
|
||||
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
|
||||
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
|
||||
|
||||
return Service.of({
|
||||
check: (target, options) =>
|
||||
locks.withLock(target)(
|
||||
Effect.gen(function* () {
|
||||
const checkedAt = yield* Clock.currentTimeMillis
|
||||
const current = status.get(target)
|
||||
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
|
||||
const outdated = yield* npm.check(target).pipe(
|
||||
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
|
||||
Effect.option,
|
||||
)
|
||||
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
|
||||
status.set(target, { outdated: value, checkedAt })
|
||||
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
|
||||
return value
|
||||
}),
|
||||
),
|
||||
update: (target) =>
|
||||
npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
|
||||
Effect.asVoid,
|
||||
),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
|
||||
@@ -20,7 +20,6 @@ import type { Patch } from "../../vcs/patch.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.vcs.git",
|
||||
vcs: { id: "git", markers: [".git"] },
|
||||
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "git") return
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.vcs.hg",
|
||||
vcs: { id: "hg", markers: [".hg"] },
|
||||
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "hg") return
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Git } from "./git.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectMarkers } from "./project/markers.js"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { ProjectTable, upsertProject } from "./project/sql.js"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
@@ -44,7 +43,6 @@ export interface Resolved {
|
||||
// This checkout's main directory; the stored project canonical may be another clone.
|
||||
readonly canonical: AbsolutePath
|
||||
readonly vcs?: Vcs
|
||||
readonly vcsBackend?: string
|
||||
}
|
||||
|
||||
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
|
||||
@@ -98,7 +96,6 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const markers = yield* ProjectMarkers.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -172,7 +169,7 @@ const layer = Layer.effect(
|
||||
if (candidate.id === item.projectID) return false
|
||||
if (!FSUtil.contains(directory, candidate.directory)) return false
|
||||
const found = yield* fs
|
||||
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
|
||||
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
if (!found[0]) return false
|
||||
return (yield* fs.resolve(path.dirname(found[0]))) === directory
|
||||
@@ -318,10 +315,9 @@ const layer = Layer.effect(
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (
|
||||
input: AbsolutePath,
|
||||
options?: { readonly discovery?: boolean },
|
||||
_options?: { readonly discovery?: boolean },
|
||||
) {
|
||||
const directory = AbsolutePath.make(yield* fs.resolve(input))
|
||||
const marker = yield* markers.discover(directory, options)
|
||||
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
@@ -330,7 +326,7 @@ const layer = Layer.effect(
|
||||
native && path.basename(native) === ".git"
|
||||
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
|
||||
: undefined
|
||||
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
|
||||
const canonical =
|
||||
@@ -346,27 +342,14 @@ const layer = Layer.effect(
|
||||
directory: repo.worktree,
|
||||
canonical,
|
||||
vcs: { type: "git" as const, store: repo.commonDirectory },
|
||||
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
|
||||
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
|
||||
if (hg) {
|
||||
return yield* persist({
|
||||
...hg,
|
||||
canonical: hg.directory,
|
||||
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
if (marker) {
|
||||
const previous = yield* cached(marker.marker)
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
|
||||
directory: marker.directory,
|
||||
canonical: marker.directory,
|
||||
vcs: { type: marker.type, store: marker.marker },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,5 +368,5 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
|
||||
})
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
export * as ProjectMarkers from "./markers.js"
|
||||
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
import { PluginModule } from "../plugin/module.js"
|
||||
import { PluginSourceDirectory } from "../plugin/source-directory.js"
|
||||
import { SdkPlugins } from "../plugin/sdk.js"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
|
||||
export interface Match {
|
||||
readonly type: string
|
||||
readonly directory: AbsolutePath
|
||||
readonly marker: AbsolutePath
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly discover: (
|
||||
directory: AbsolutePath,
|
||||
options?: { readonly discovery?: boolean },
|
||||
) => Effect.Effect<Match | undefined>
|
||||
readonly targets: () => readonly string[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const npm = yield* Npm.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const known = new Set([".git", ".hg"])
|
||||
const loaded = new Map<string, Versioned | undefined>()
|
||||
|
||||
// The filesystem half of discovery: walk up for config, scan plugin
|
||||
// directories, and read configured plugin operations. This is the part a
|
||||
// no-discovery caller must skip — it imports plugin modules.
|
||||
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
const found = yield* fs
|
||||
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
|
||||
const files = [
|
||||
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
|
||||
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
|
||||
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
|
||||
]
|
||||
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
return yield* Effect.forEach(
|
||||
[
|
||||
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
|
||||
...configured,
|
||||
],
|
||||
(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),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const discover = Effect.fn("ProjectMarkers.discover")(function* (
|
||||
directory: AbsolutePath,
|
||||
options?: { readonly discovery?: boolean },
|
||||
) {
|
||||
// discovery: false skips the config scan and its plugin module loading;
|
||||
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
|
||||
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
|
||||
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
|
||||
|
||||
for (const plugin of sdk.all()) {
|
||||
if (!plugin.vcs) continue
|
||||
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
|
||||
}
|
||||
|
||||
for (const operation of operations) {
|
||||
if (operation.type === "remove") {
|
||||
for (const id of declarations.keys()) {
|
||||
if (
|
||||
operation.target === "*" ||
|
||||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
|
||||
) {
|
||||
declarations.delete(id)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
|
||||
continue
|
||||
const key = JSON.stringify(operation)
|
||||
const plugin = loaded.has(key)
|
||||
? loaded.get(key)
|
||||
: yield* PluginModule.load(operation).pipe(
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logDebug("failed to discover plugin repository markers", {
|
||||
target: operation.target,
|
||||
cause,
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
|
||||
)
|
||||
if (!plugin?.vcs) continue
|
||||
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
|
||||
}
|
||||
|
||||
const markers = new Map<string, string>()
|
||||
for (const declaration of declarations.values()) {
|
||||
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
|
||||
for (const marker of declaration.markers) {
|
||||
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
|
||||
known.add(marker)
|
||||
markers.set(marker, declaration.id)
|
||||
}
|
||||
}
|
||||
if (!markers.size) return undefined
|
||||
|
||||
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
|
||||
Effect.map((entries) => entries[0]),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!marker) return undefined
|
||||
const type = markers.get(path.basename(marker))
|
||||
if (!type) return undefined
|
||||
return {
|
||||
type,
|
||||
directory: AbsolutePath.make(path.dirname(marker)),
|
||||
marker: AbsolutePath.make(marker),
|
||||
} satisfies Match
|
||||
})
|
||||
|
||||
return Service.of({ discover, targets: () => [...known] })
|
||||
}),
|
||||
)
|
||||
|
||||
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
|
||||
return Effect.gen(function* () {
|
||||
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!source) return []
|
||||
const errors: ParseError[] = []
|
||||
const document: unknown = parse(source, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
|
||||
if (!Array.isArray(document.plugins)) return []
|
||||
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
|
||||
if (typeof entry === "string" && entry.startsWith("-")) {
|
||||
return [{ type: "remove", target: entry.slice(1) }]
|
||||
}
|
||||
if (
|
||||
typeof entry !== "string" &&
|
||||
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
const options =
|
||||
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
|
||||
? Object.fromEntries(Object.entries(entry.options))
|
||||
: {}
|
||||
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
|
||||
if (target.startsWith("./") || target.startsWith("../")) {
|
||||
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
|
||||
}
|
||||
return [{ type: "add", target, options }]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import { Project } from "./project.js"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instance } from "./instance/service.js"
|
||||
import { Database } from "./database/database.js"
|
||||
@@ -45,14 +44,11 @@ import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
import { SessionGenerate } from "./session/generate.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
@@ -184,20 +180,6 @@ export interface Interface {
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["prompt"]>
|
||||
/** Generates text from current Session context without admitting input or mutating history. */
|
||||
readonly generate: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
text: string
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
|
||||
readonly shell: (
|
||||
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["shell"]>
|
||||
@@ -398,33 +380,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
|
||||
generate: Effect.fn("Session.generate")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
|
||||
return yield* generate.generate(input)
|
||||
}),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const commands = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Command.Service
|
||||
}).pipe(instances.provide(session))
|
||||
const delivery = input.delivery ?? "steer"
|
||||
yield* commands.execute({
|
||||
name: input.command,
|
||||
invocation: {
|
||||
sessionID: input.sessionID,
|
||||
prompt: {
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
},
|
||||
delivery,
|
||||
},
|
||||
})
|
||||
}),
|
||||
shell: (input) => sessions.forSession(input.sessionID).shell(input),
|
||||
skill: (input) => sessions.forSession(input.sessionID).skill(input),
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -10,18 +10,21 @@ import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { State } from "../state.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
const DEFAULT_KEEP_TOKENS = 15_000
|
||||
const OUTPUT_TOKEN_MAX = 32_000
|
||||
const TOOL_OUTPUT_MAX_CHARS = 2_000
|
||||
const IMAGE_TOKEN_ESTIMATE = 1_500
|
||||
const PDF_TOKEN_ESTIMATE = 2_000
|
||||
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
||||
<template>
|
||||
## Objective
|
||||
@@ -71,7 +74,9 @@ export type AutoInput = {
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
|
||||
readonly context: SessionContext.Loaded
|
||||
}
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
@@ -107,6 +112,67 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
export const estimateTokens = (input: RequiredInput) => {
|
||||
const index = input.messages.findLastIndex(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
!message.error &&
|
||||
message.tokens !== undefined &&
|
||||
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
|
||||
)
|
||||
const last = input.messages[index]
|
||||
// Keep the anchor's local tool results: they are not covered by its provider usage.
|
||||
const added = SessionModelRequest.unsupportedParts(
|
||||
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
|
||||
input.resolved.capabilities,
|
||||
)
|
||||
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
|
||||
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
|
||||
if (last?.type === "assistant" && last.tokens)
|
||||
return (
|
||||
added +
|
||||
last.tokens.input +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: input.context.agent.info,
|
||||
model: input.resolved,
|
||||
tools: input.context.tools,
|
||||
initial: input.context.initial,
|
||||
messages: [],
|
||||
})
|
||||
return (
|
||||
added +
|
||||
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
|
||||
input.context.tools.definitions.reduce(
|
||||
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
|
||||
0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const estimateMedia = (mime: string) => {
|
||||
const type = mime.toLowerCase()
|
||||
return type.startsWith("image/") ? IMAGE_TOKEN_ESTIMATE : type === "application/pdf" ? PDF_TOKEN_ESTIMATE : 0
|
||||
}
|
||||
|
||||
const estimatePart = (part: ContentPart): number => {
|
||||
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
|
||||
if (part.type === "media") return estimateMedia(part.mediaType)
|
||||
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
|
||||
if (part.result.type === "content")
|
||||
return part.result.value.reduce(
|
||||
(sum, content) => sum + (content.type === "text" ? Token.estimate(content.text) : estimateMedia(content.mime)),
|
||||
0,
|
||||
)
|
||||
return Token.estimate(
|
||||
typeof part.result.value === "string" ? part.result.value : (JSON.stringify(part.result.value) ?? ""),
|
||||
)
|
||||
}
|
||||
|
||||
export const truncateToolOutput = (value: string) => {
|
||||
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
|
||||
let end = 0
|
||||
@@ -364,27 +430,18 @@ export const layer = Layer.effect(
|
||||
const required = (input: RequiredInput) => {
|
||||
const config = state.get()
|
||||
if (!config.auto) return false
|
||||
// Run the completed checkpoint before considering another automatic compaction.
|
||||
const last = input.messages.at(-1)
|
||||
if (last?.type === "compaction" && last.status === "completed") return false
|
||||
const limit = input.resolved.limit
|
||||
const context = limit.context
|
||||
if (context <= 0) return false
|
||||
const last = input.messages.findLast(
|
||||
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= promptCeiling
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, state.get().tokens)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
|
||||
import { Location } from "../location.js"
|
||||
import { McpInstructions } from "../mcp/instructions.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
@@ -85,7 +85,7 @@ const layer = Layer.effect(
|
||||
const mcpTools = yield* McpTool.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
const skillInstructions = yield* SkillInstructions.Service
|
||||
const store = yield* SessionStore.Service
|
||||
@@ -125,7 +125,7 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
@@ -190,7 +190,7 @@ export const node = makeLocationNode({
|
||||
Location.node,
|
||||
McpInstructions.node,
|
||||
McpTool.node,
|
||||
PluginSupervisor.node,
|
||||
Plugin.node,
|
||||
ReferenceInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelRequest.node,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export * as SessionPromptNode from "./prompt-node.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Image } from "../image.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { SessionPrompt } from "./prompt.js"
|
||||
|
||||
// Keep the supervisor implementation out of the global Session import path.
|
||||
export const node = makeLocationNode({
|
||||
service: SessionPrompt.Service,
|
||||
layer: SessionPrompt.layer,
|
||||
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
|
||||
})
|
||||
@@ -6,13 +6,14 @@ import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Image } from "../image.js"
|
||||
import { Mime } from "../mime.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { AttachmentError, SkillNotFoundError } from "./error.js"
|
||||
|
||||
@@ -27,7 +28,7 @@ export type Input = {
|
||||
|
||||
export const make = Effect.fn("SessionPrompt.make")(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const skillService = yield* Skill.Service
|
||||
@@ -37,7 +38,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
|
||||
messageID: SessionMessage.ID
|
||||
input: Input
|
||||
}) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const event = yield* hooks.trigger("session", "prompt", {
|
||||
sessionID: request.sessionID,
|
||||
messageID: request.messageID,
|
||||
@@ -205,6 +206,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Plugin.node, PluginHooks.node, Image.node, Skill.node],
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
function decodeDataURL(uri: string) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export * as SessionRevertNode from "./revert-node.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { SessionRevert } from "./revert.js"
|
||||
|
||||
// Keep the supervisor implementation out of the global Session import path.
|
||||
export const node = makeLocationNode({
|
||||
service: SessionRevert.Service,
|
||||
layer: SessionRevert.layer,
|
||||
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
|
||||
})
|
||||
@@ -2,9 +2,10 @@ export * as SessionRevert from "./revert.js"
|
||||
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor-service.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { RelativePath } from "../schema.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -34,11 +35,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
|
||||
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
const original = input.session.revert?.snapshot
|
||||
? Snapshot.ID.make(input.session.revert.snapshot)
|
||||
: yield* snapshot.capture()
|
||||
@@ -66,7 +67,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
})
|
||||
|
||||
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
|
||||
yield* plugins.flush
|
||||
yield* plugins.awaitActivation
|
||||
if (!session.revert) return
|
||||
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
|
||||
if (original)
|
||||
@@ -83,6 +84,12 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Database.node, Bus.node, Plugin.node, Snapshot.node],
|
||||
})
|
||||
|
||||
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
|
||||
if (!session.revert) return
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user