mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 14:36:20 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f6a3d659 | ||
|
|
f302d84ab5 | ||
|
|
e578ccf940 |
@@ -969,7 +969,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -979,7 +978,6 @@
|
||||
"@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:",
|
||||
},
|
||||
},
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
|
||||
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
|
||||
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
|
||||
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
|
||||
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
|
||||
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
|
||||
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
|
||||
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,12 +1257,9 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
if (
|
||||
item.type !== "function_call" ||
|
||||
!item.call_id ||
|
||||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
|
||||
)
|
||||
continue
|
||||
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
|
||||
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 item completion and its terminal item id are missing", () =>
|
||||
it.effect("uses completed response output when output item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
@@ -4032,6 +4032,7 @@ 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, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -131,8 +131,10 @@ async function setup(page: Page) {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
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:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -142,6 +144,6 @@ async function setup(page: Page) {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -88,8 +88,10 @@ 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" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -99,7 +101,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -64,7 +64,7 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
|
||||
location: project ? { directory: project } : {},
|
||||
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
|
||||
id,
|
||||
source: { type: "package", target: id },
|
||||
source: { type: "package", package: id },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
})),
|
||||
|
||||
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
({ directory, server, sessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -69,8 +69,10 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
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:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
@@ -80,7 +82,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
{ directory, server, sessionID },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
|
||||
@@ -22,7 +22,6 @@ 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,72 +18,37 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// 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 }) => {
|
||||
// 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 }) => {
|
||||
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 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 expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
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)
|
||||
@@ -137,7 +102,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions, panes }) => {
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -149,19 +114,8 @@ 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,17 +123,12 @@ 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(
|
||||
({ 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.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
|
||||
@@ -85,7 +85,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
|
||||
data: [
|
||||
{
|
||||
id: "demo-plugin",
|
||||
source: { type: "package", target: "demo-plugin" },
|
||||
source: { type: "package", package: "demo-plugin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
|
||||
@@ -252,15 +252,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
return page.addInitScript(
|
||||
({ 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 }]),
|
||||
)
|
||||
({ terminalKey, ptyID }) => {
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||
localStorage.setItem(
|
||||
terminalKey,
|
||||
JSON.stringify({
|
||||
@@ -269,13 +262,7 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
terminalKey: terminalStorageKey(),
|
||||
ptyID,
|
||||
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
server,
|
||||
sessionID,
|
||||
},
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// 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 }) => {
|
||||
// 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 }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
@@ -26,10 +27,7 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
|
||||
|
||||
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`)
|
||||
@@ -39,26 +37,15 @@ test("keeps terminal visibility per tab and the PTY alive across tab switches",
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
|
||||
await expect(terminal).toBeVisible()
|
||||
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 }
|
||||
@@ -133,7 +120,7 @@ async function setup(page: Page) {
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions, panes }) => {
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
@@ -145,20 +132,8 @@ 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,12 +75,11 @@ 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 expectToken(
|
||||
await expectBackground(
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,16 +245,3 @@ 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,4 +1,4 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { ComposerAttachment, ComposerPrompt } from "../types"
|
||||
|
||||
@@ -78,7 +78,6 @@ export type ComposerAttachmentConfig = {
|
||||
onError: (error: unknown) => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
onDragCancel?: (callback: () => void) => () => void
|
||||
store?: (file: File) => Promise<{ id: string; url: string }>
|
||||
}
|
||||
|
||||
@@ -91,9 +90,6 @@ export function createComposerAttachments(
|
||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||
},
|
||||
) {
|
||||
const clearDrag = () => {
|
||||
input.setDraggingType(null)
|
||||
}
|
||||
const capture = () => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
@@ -182,7 +178,7 @@ export function createComposerAttachments(
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
clearDrag()
|
||||
input.setDraggingType(null)
|
||||
const plainText = event.dataTransfer?.getData("text/plain")
|
||||
if (plainText?.startsWith("file:")) {
|
||||
const path = plainText.slice("file:".length)
|
||||
@@ -195,8 +191,6 @@ export function createComposerAttachments(
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const cancel = input.onDragCancel?.(clearDrag)
|
||||
if (cancel) onCleanup(cancel)
|
||||
makeEventListener(document, "dragover", (event) => {
|
||||
if (input.isDialogActive()) return
|
||||
event.preventDefault()
|
||||
@@ -204,10 +198,7 @@ export function createComposerAttachments(
|
||||
else if (event.dataTransfer?.types.includes("text/plain")) input.setDraggingType("@mention")
|
||||
})
|
||||
makeEventListener(document, "dragleave", (event) => {
|
||||
if (!input.isDialogActive() && !event.relatedTarget) clearDrag()
|
||||
})
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.key === "Escape") clearDrag()
|
||||
if (!input.isDialogActive() && !event.relatedTarget) input.setDraggingType(null)
|
||||
})
|
||||
makeEventListener(document, "drop", handleDrop)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -12,33 +12,10 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export type ComposerDropState = { active: boolean; label: string }
|
||||
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
onDropStateChange?: (state: ComposerDropState) => void
|
||||
}) {
|
||||
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const dropInput = () => props.model.model.selection.current()?.capabilities.input
|
||||
const dropLabel = createMemo(() => {
|
||||
const input = dropInput()
|
||||
if (!input?.image && !input?.pdf) return language.t("ui.promptInput.dropFiles")
|
||||
if (!input.pdf) return language.t("ui.promptInput.dropFiles.image")
|
||||
if (!input.image) return language.t("ui.promptInput.dropFiles.pdf")
|
||||
return language.t("ui.promptInput.dropFiles.imagePdf")
|
||||
})
|
||||
|
||||
createEffect(() =>
|
||||
props.onDropStateChange?.({
|
||||
active: props.model.state.drag === "active",
|
||||
label: dropLabel(),
|
||||
}),
|
||||
)
|
||||
onCleanup(() => props.onDropStateChange?.({ active: false, label: dropLabel() }))
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -117,6 +117,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -127,6 +128,12 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onDragLeave={props.controller.onDragLeave}
|
||||
onDrop={props.controller.onDrop}
|
||||
>
|
||||
<Show when={state.drag === "active"}>
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
|
||||
{i18n.t("ui.promptInput.dropFiles")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
|
||||
@@ -345,7 +345,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
onDragCancel: platform.onDragCancel,
|
||||
store: platform.draftStore?.putBlob,
|
||||
},
|
||||
view: {
|
||||
|
||||
@@ -314,69 +314,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-content-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-upload-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes session-dropzone-stripes-drift {
|
||||
to {
|
||||
background-position: 33.941px 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] {
|
||||
animation: session-dropzone-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"] {
|
||||
animation: session-dropzone-content-enter 200ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"] {
|
||||
animation: session-dropzone-upload-enter 240ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: session-dropzone-stripes-drift 3s linear infinite;
|
||||
}
|
||||
|
||||
[data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 4%;
|
||||
--session-dropzone-stripe: 7%;
|
||||
--session-dropzone-card: var(--v2-background-bg-base);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="session-dropzone"] {
|
||||
--session-dropzone-wash: 6%;
|
||||
--session-dropzone-stripe: 14%;
|
||||
--session-dropzone-card: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-dropzone"][data-visible="true"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-content"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-upload"],
|
||||
[data-component="session-dropzone"][data-visible="true"] [data-slot="session-dropzone-stripes"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"] {
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("pluginLabels", () => {
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "package-plugin",
|
||||
source: { type: "package", target: "example" },
|
||||
source: { type: "package", package: "example" },
|
||||
state: { status: "active" },
|
||||
features: { server: true },
|
||||
},
|
||||
|
||||
@@ -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.target
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import createPresence from "solid-presence"
|
||||
|
||||
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),
|
||||
}
|
||||
},
|
||||
)
|
||||
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,
|
||||
}
|
||||
})
|
||||
const presence = createPresence({ show: () => animation().show, element })
|
||||
return {
|
||||
...presence,
|
||||
|
||||
@@ -59,9 +59,6 @@ type PlatformBase = {
|
||||
/** Resolve the native source path for a desktop File. */
|
||||
getPathForFile?(file: File): string
|
||||
|
||||
/** Observe native drag cancellation that does not reach the renderer event loop. */
|
||||
onDragCancel?(callback: () => void): () => void
|
||||
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
|
||||
@@ -155,16 +155,6 @@ 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,9 +36,10 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
|
||||
}
|
||||
|
||||
function unwrapNamedError(error: unknown): unknown {
|
||||
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
|
||||
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
|
||||
return (error.cause as Record<string, unknown>).body
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
// Client-synthesized session not-found errors share one constructor and
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/sc
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, Show } from "solid-js"
|
||||
import { Composer, type ComposerDropState } from "@/composer/composer"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { createComposerModel, type ComposerModel } from "@/composer/model"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -218,7 +218,6 @@ export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
onResponseSubmit: () => void
|
||||
onDropStateChange?: (state: ComposerDropState) => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const location = useWorkspaceLocation()
|
||||
@@ -264,7 +263,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay onDropStateChange={props.onDropStateChange} />
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||
import type { FileNode } from "@/runtime/server/types"
|
||||
|
||||
describe("buildFileTreeV2Model", () => {
|
||||
@@ -45,33 +45,6 @@ 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,12 +76,6 @@ 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,7 +129,6 @@ export function useSessionModel() {
|
||||
layout: {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -18,7 +18,6 @@ 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"
|
||||
@@ -84,9 +83,6 @@ 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")))
|
||||
@@ -97,7 +93,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 = navigationFiles()
|
||||
const files = filteredFiles()
|
||||
if (active && files.includes(active)) return active
|
||||
return files[0]
|
||||
})
|
||||
@@ -145,7 +141,7 @@ export function ReviewPanelView(
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
files={navigationFiles()}
|
||||
files={filteredFiles()}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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())
|
||||
@@ -43,7 +42,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
|
||||
const resizedWidth = createMemo(() =>
|
||||
clampSessionPanelWidth({
|
||||
width: view().reviewPanel.width(),
|
||||
width: layout.session.width(),
|
||||
available: available(),
|
||||
split: splitReview(),
|
||||
}),
|
||||
@@ -73,7 +72,7 @@ export function createSessionScreenLayout(session: SessionModel) {
|
||||
}, panelLayout().stacked)
|
||||
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
|
||||
const terminalPane = createMemo(() =>
|
||||
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
|
||||
)
|
||||
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
|
||||
const sideHeight = createMemo(() => rowSize.height)
|
||||
|
||||
@@ -11,9 +11,10 @@ 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 { Icon } from "@opencode-ai/ui/icon"
|
||||
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"
|
||||
@@ -30,7 +31,6 @@ 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,6 +39,7 @@ 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()
|
||||
@@ -57,55 +58,22 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideTerminalPresent: false,
|
||||
mobileTerminalCached: false,
|
||||
mobileMoveDismissed: false,
|
||||
drop: { active: false, label: "" },
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
bottomTerminal?: HTMLDivElement
|
||||
dropzone?: HTMLDivElement
|
||||
}>({})
|
||||
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 = createAnimatedPresence(
|
||||
() => sideVisible() || undefined,
|
||||
() => elements.side ?? null,
|
||||
session.layout.tabKey,
|
||||
)
|
||||
const bottomTerminalPresence = createAnimatedPresence(
|
||||
() => bottomTerminalVisible() || undefined,
|
||||
() => elements.bottomTerminal ?? null,
|
||||
session.layout.tabKey,
|
||||
)
|
||||
const dropPresence = createAnimatedPresence(
|
||||
() => store.drop.active || undefined,
|
||||
() => elements.dropzone ?? 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 sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
})
|
||||
const bottomTerminalPresence = createPresence({
|
||||
show: bottomTerminalVisible,
|
||||
element: () => elements.bottomTerminal ?? null,
|
||||
})
|
||||
const paneAnimating = () =>
|
||||
sidePresence.animate() ||
|
||||
sideMotion().animateRegion ||
|
||||
sideMotion().animateTerminal ||
|
||||
bottomTerminalPresence.animate()
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
@@ -206,64 +174,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<div
|
||||
data-slot="session-dropzone-blur"
|
||||
data-visible={store.drop.active}
|
||||
class="pointer-events-none absolute inset-0 z-[79] rounded-[inherit] opacity-[0.001] backdrop-blur-[1.5px] transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] will-change-[opacity,backdrop-filter] data-[visible=true]:opacity-100 motion-reduce:transition-none"
|
||||
style={{
|
||||
"-webkit-mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"-webkit-mask-composite": "source-in",
|
||||
"mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 25%, black 75%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 28%, black 72%, transparent 100%)",
|
||||
"mask-composite": "intersect",
|
||||
}}
|
||||
/>
|
||||
<Show when={dropPresence.present()}>
|
||||
<div
|
||||
ref={(element) => setElements("dropzone", element)}
|
||||
data-component="session-dropzone"
|
||||
data-visible={store.drop.active}
|
||||
class="pointer-events-none absolute inset-0 z-[80] grid place-items-center overflow-hidden rounded-[inherit] bg-[color-mix(in_srgb,var(--v2-text-text-base)_var(--session-dropzone-wash),transparent)] opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
>
|
||||
<div class="absolute inset-0 bg-v2-background-bg-base/25" />
|
||||
<div
|
||||
class="absolute inset-y-0 left-1/2 w-full -translate-x-1/2 md:max-w-200 2xl:max-w-[1000px]"
|
||||
style={{
|
||||
"-webkit-mask-image":
|
||||
"linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
"mask-image": "linear-gradient(to right, transparent 0%, black 12%, black 88%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-stripes"
|
||||
class="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
background:
|
||||
"repeating-linear-gradient(135deg, transparent 0px, transparent 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 12px, color-mix(in srgb, var(--v2-text-text-base) var(--session-dropzone-stripe), transparent) 24px)",
|
||||
"-webkit-mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
"mask-image":
|
||||
"radial-gradient(ellipse 59% 40% at center, black 0%, rgba(0,0,0,0.72) 58%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-slot="session-dropzone-content"
|
||||
class="relative flex translate-y-0 flex-col items-center gap-5 opacity-100 transition-[opacity,transform] duration-200 ease-[cubic-bezier(0.215,0.61,0.355,1)] data-[visible=false]:translate-y-1 data-[visible=false]:opacity-0 motion-reduce:transition-none"
|
||||
data-visible={store.drop.active}
|
||||
>
|
||||
<div
|
||||
data-slot="session-dropzone-upload"
|
||||
class="flex size-10 items-center justify-center rounded-full bg-[var(--session-dropzone-card)] text-v2-icon-icon-muted shadow-[var(--v2-elevation-floating)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="arrow-up" size="normal" class="text-v2-icon-icon-muted" />
|
||||
</div>
|
||||
<div class="text-[15px] font-[530] leading-6 text-v2-text-text-base">{store.drop.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
@@ -331,12 +241,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion
|
||||
model={composer}
|
||||
session={session}
|
||||
onResponseSubmit={timeline.actions.resume}
|
||||
onDropStateChange={(drop) => setStore("drop", drop)}
|
||||
/>
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
@@ -351,8 +256,7 @@ 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() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
!screen.size.active(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
style={{
|
||||
@@ -375,18 +279,18 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
max={screen.panel.max()}
|
||||
onResize={(width) => {
|
||||
screen.size.touch()
|
||||
session.layout.view().reviewPanel.resize(width)
|
||||
layout.session.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
|
||||
<Show when={sidePresence.present() || store.sideTerminalPresent}>
|
||||
<div
|
||||
ref={(element) => setElements("side", element)}
|
||||
data-slot="session-side-panel-presence"
|
||||
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
|
||||
data-opened={sideVisible()}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
@@ -407,15 +311,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 && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
>
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
data-slot="session-side-region-presence"
|
||||
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
|
||||
data-opened={screen.side.region.open()}
|
||||
class="absolute inset-0"
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
@@ -437,7 +341,6 @@ 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()}
|
||||
@@ -446,13 +349,13 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={session.layout.view().terminal.height()}
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
session.layout.view().terminal.resize(height)
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
@@ -462,15 +365,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 && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion,
|
||||
}}
|
||||
style={{ height: screen.side.terminal.height() }}
|
||||
>
|
||||
<Show when={store.sideTerminalPresent}>
|
||||
<div
|
||||
data-slot="side-terminal-panel-presence"
|
||||
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
|
||||
data-opened={sideTerminalVisible()}
|
||||
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]">
|
||||
@@ -478,7 +381,6 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
fill
|
||||
framed={false}
|
||||
present={store.sideTerminalPresent}
|
||||
animate={sidePresence.animate() || sideMotion().animateTerminal}
|
||||
contentHeight={screen.side.terminal.contentHeight()}
|
||||
/>
|
||||
</div>
|
||||
@@ -495,7 +397,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
|
||||
data-opened={bottomTerminalVisible()}
|
||||
classList={{
|
||||
hidden: !bottomTerminalPresence.present(),
|
||||
"relative min-h-0 shrink-0": isDesktop(),
|
||||
@@ -506,23 +408,19 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={session.layout.view().terminal.height()}
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
screen.size.touch()
|
||||
session.layout.view().terminal.resize(height)
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => session.layout.view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<TerminalPanel
|
||||
stacked={isDesktop()}
|
||||
present={store.bottomTerminalCached}
|
||||
animate={bottomTerminalPresence.animate()}
|
||||
/>
|
||||
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
export function SessionPanelFrame(props: ParentProps<{ raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[10px] bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": props.raised,
|
||||
}}
|
||||
|
||||
@@ -5,8 +5,6 @@ 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()
|
||||
@@ -21,32 +19,12 @@ 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, panes)),
|
||||
view: createMemo(() => layout.view(sessionKey)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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"
|
||||
@@ -44,9 +45,9 @@ export function TerminalPanel(
|
||||
present?: boolean
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
animate?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const language = useLanguage()
|
||||
@@ -56,7 +57,7 @@ export function TerminalPanel(
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => view().terminal.height())
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
@@ -237,11 +238,10 @@ export function TerminalPanel(
|
||||
pane={pane()}
|
||||
max={max()}
|
||||
resizing={size.active()}
|
||||
animate={props.animate}
|
||||
onResizeStart={size.start}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
view().terminal.resize(next)
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,6 @@ export function TerminalSurface(
|
||||
pane: number
|
||||
max: number
|
||||
resizing: boolean
|
||||
animate?: boolean
|
||||
onResizeStart: () => void
|
||||
onResize: (height: number) => void
|
||||
onCollapse: () => void
|
||||
@@ -28,9 +27,7 @@ export function TerminalSurface(
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={
|
||||
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
|
||||
}
|
||||
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
|
||||
@@ -478,11 +478,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative w-full h-full min-w-0"
|
||||
data-workspace-session={props.workspaceSession() ? "" : undefined}
|
||||
data-local-session={!props.workspaceSession() ? "" : undefined}
|
||||
>
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-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,16 +59,6 @@ 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" }
|
||||
@@ -518,7 +508,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
return message
|
||||
},
|
||||
},
|
||||
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
|
||||
const reviewMode = createMemo(() => {
|
||||
@@ -529,24 +519,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const file = s().reviewFile
|
||||
if (typeof file === "string") return file
|
||||
})
|
||||
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 terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
|
||||
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 })
|
||||
@@ -560,13 +537,6 @@ 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(() => {
|
||||
@@ -596,14 +566,6 @@ 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)
|
||||
},
|
||||
@@ -617,14 +579,6 @@ 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 { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { migrateTabs } from "./migration"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
|
||||
@@ -48,12 +48,8 @@ test("session tab identity stays rooted while its href follows the child route",
|
||||
})
|
||||
|
||||
test("finds open root and routed session tabs", () => {
|
||||
const tab = { ...sessionTab("root"), routeSessionId: "child" }
|
||||
const tabs = [tab]
|
||||
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
|
||||
|
||||
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,10 +46,6 @@ 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
|
||||
}
|
||||
@@ -66,8 +62,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
|
||||
return sessionIDHasOpenTab(tabs, server, session.id)
|
||||
}
|
||||
|
||||
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
|
||||
return tabs.find(
|
||||
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
|
||||
return tabs.some(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
tab.server === server &&
|
||||
@@ -75,10 +71,6 @@ export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessio
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -97,10 +89,6 @@ 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>>({})
|
||||
|
||||
@@ -153,15 +141,6 @@ 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(() => {
|
||||
@@ -174,7 +153,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
memory.remove(key)
|
||||
removeInfo(key)
|
||||
removePanes(key)
|
||||
}
|
||||
}
|
||||
setStore(() => next)
|
||||
@@ -184,10 +162,6 @@ 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(() => {
|
||||
@@ -224,7 +198,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
removeInfo(key)
|
||||
removePanes(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
}
|
||||
|
||||
@@ -518,38 +491,8 @@ 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, panesReady }
|
||||
return { ...actions, store, info, ready, infoReady, recentReady }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createAnimatedPresence } from "../src/runtime/animated-presence"
|
||||
import { batch, createRoot, createSignal } from "solid-js"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
|
||||
test("animates visibility changes without animating initial presence", () => {
|
||||
createRoot((dispose) => {
|
||||
@@ -47,22 +47,3 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { render } from "solid-js/web"
|
||||
import { createComposerAttachments } from "@/composer/attachments/attachments"
|
||||
|
||||
test("clears drag state on platform cancellation", async () => {
|
||||
let cancel = () => {}
|
||||
let subscribed = false
|
||||
let dragging: "image" | "@mention" | null = "image"
|
||||
const dispose = render(() => {
|
||||
createComposerAttachments({
|
||||
capture: () => ({ current: () => [], cursor: () => 0, set: () => {} }),
|
||||
editor: () => undefined,
|
||||
focusEditor: () => {},
|
||||
addPart: () => false,
|
||||
setDraggingType: (type) => (dragging = type),
|
||||
directory: () => "",
|
||||
isDialogActive: () => false,
|
||||
warn: () => {},
|
||||
duplicate: () => {},
|
||||
onError: () => {},
|
||||
onDragCancel: (callback) => {
|
||||
cancel = callback
|
||||
subscribed = true
|
||||
return () => (subscribed = false)
|
||||
},
|
||||
})
|
||||
return null
|
||||
}, document.createElement("div"))
|
||||
await Promise.resolve()
|
||||
|
||||
cancel()
|
||||
|
||||
expect(dragging).toBeNull()
|
||||
dispose()
|
||||
expect(subscribed).toBeFalse()
|
||||
})
|
||||
@@ -54,7 +54,7 @@ export function format(
|
||||
plugin.state.status !== "active" || !plugin.features.tui
|
||||
? []
|
||||
: plugin.source.type === "package"
|
||||
? [{ target: plugin.source.target, source: "advertised" as const }]
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: plugin.source.type === "local"
|
||||
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
|
||||
: [],
|
||||
@@ -73,7 +73,7 @@ export function format(
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.target
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", target: "acme-plugin@1.0.0" },
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, tui: true },
|
||||
},
|
||||
{
|
||||
source: { type: "package", target: "broken-plugin" },
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
state: { status: "failed", error: "broken" },
|
||||
features: { server: true },
|
||||
},
|
||||
|
||||
@@ -198,13 +198,7 @@ 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",
|
||||
// Bun 1.4 can reuse cached modules compiled with different --define values.
|
||||
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0" },
|
||||
},
|
||||
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
|
||||
)
|
||||
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
||||
expect(code, stderr).toBe(0)
|
||||
|
||||
@@ -88,16 +88,8 @@ 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 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 update: PluginUpdateOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -324,17 +322,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).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),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
|
||||
type Client = ReturnType<typeof OpenCode.make>
|
||||
|
||||
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
|
||||
export type { PermissionCreateInput } from "./generated/types.js"
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -468,19 +466,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
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,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true }
|
||||
| { type: "package"; package: string }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
@@ -214,7 +214,6 @@ export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
@@ -1817,7 +1816,6 @@ export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
@@ -1995,7 +1993,6 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
@@ -2357,14 +2354,6 @@ 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"
|
||||
@@ -2426,6 +2415,14 @@ 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
|
||||
@@ -2585,15 +2582,6 @@ export type PluginListOutput = {
|
||||
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
|
||||
|
||||
@@ -78,43 +78,16 @@ const docTags = (schema: JsonSchema): Array<string> => {
|
||||
return tags
|
||||
}
|
||||
|
||||
const docLines = (schema: JsonSchema, width: number): Array<string> => {
|
||||
const summary = docTags(schema).join(" ")
|
||||
const lines = (schema.description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
|
||||
// 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+$/, ""),
|
||||
)
|
||||
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
|
||||
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
|
||||
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")
|
||||
if (lines.length === 0) return ""
|
||||
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
|
||||
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
|
||||
return `${pad}/**\n${body}\n${pad} */\n`
|
||||
}
|
||||
|
||||
@@ -193,7 +166,9 @@ const renderSchema = (
|
||||
|
||||
if (properties.length === 0 && indexType === undefined) return "{}"
|
||||
const pad = " ".repeat(depth + 1)
|
||||
const lines = properties.map((entry) => `${jsdoc(entry[1], pad)}${pad}${field(entry)},`)
|
||||
const lines = properties.map(
|
||||
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
|
||||
)
|
||||
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
|
||||
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
|
||||
})
|
||||
|
||||
describe("pretty signature rendering", () => {
|
||||
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
|
||||
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
|
||||
expect(inputTypeScript(listIssues, true)).toBe(
|
||||
[
|
||||
"{",
|
||||
@@ -44,9 +44,16 @@ 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",',
|
||||
"}",
|
||||
@@ -98,7 +105,7 @@ describe("pretty signature rendering", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("constraints and annotations share compact tagged JSDoc", () => {
|
||||
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "object",
|
||||
@@ -112,10 +119,19 @@ 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 */\n tags?: Array<string>')
|
||||
expect(pretty).toContain(
|
||||
[
|
||||
" /**",
|
||||
' * @default ["a","b"]',
|
||||
" * @minItems 2",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" tags?: Array<string>",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("skips an unserializable default rather than emitting a broken summary", () => {
|
||||
test("skips an unserializable default rather than emitting a broken tag", () => {
|
||||
const pretty = jsonSchemaToTypeScript(
|
||||
{ type: "object", properties: { size: { type: "number", default: 1n } } },
|
||||
true,
|
||||
@@ -123,175 +139,6 @@ 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"],
|
||||
@@ -304,18 +151,9 @@ describe("pretty signature rendering", () => {
|
||||
[{ 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) => {
|
||||
] as const)("renders constraint %j without changing the compact type", (value, tag, type) => {
|
||||
const schema = { type: "object", properties: { value } }
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
|
||||
)
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(["{", ` /** ${tag} */`, ` value?: ${type},`, "}"].join("\n"))
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
|
||||
})
|
||||
|
||||
@@ -350,19 +188,30 @@ describe("pretty signature rendering", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
|
||||
test.each([false, null, ""])("preserves default %j alongside constraint tags", (value) => {
|
||||
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
|
||||
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
|
||||
` * @default ${JSON.stringify(value)}\n * @minLength 0\n`,
|
||||
)
|
||||
})
|
||||
|
||||
test("escapes comment terminators in summary values", () => {
|
||||
test("escapes comment terminators in tag values", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
|
||||
true,
|
||||
),
|
||||
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" /**",
|
||||
' * @default "* /"',
|
||||
" * @format * /",
|
||||
" * @pattern ^a* /b$",
|
||||
" */",
|
||||
" value?: string,",
|
||||
"}",
|
||||
].join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
|
||||
@@ -387,88 +236,6 @@ 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",
|
||||
@@ -693,10 +460,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
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 } },
|
||||
labels: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
|
||||
},
|
||||
required: ["count", "name", "labels", "scores"],
|
||||
required: ["count", "name", "labels"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -704,11 +470,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
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))),
|
||||
labels: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(5)),
|
||||
}),
|
||||
},
|
||||
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
|
||||
@@ -718,25 +480,29 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
description: "Constrained tool",
|
||||
input: schema,
|
||||
output: schema,
|
||||
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"], scores: { test: 1 } }),
|
||||
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"] }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const type = [
|
||||
"{",
|
||||
" /** @integer @minimum 0 @maximum 10 */",
|
||||
" /**",
|
||||
" * @integer",
|
||||
" * @minimum 0",
|
||||
" * @maximum 10",
|
||||
" */",
|
||||
" count: number,",
|
||||
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
|
||||
" /**",
|
||||
" * @minLength 1",
|
||||
" * @maxLength 20",
|
||||
" * @pattern ^[a-z]+$",
|
||||
" */",
|
||||
" name: string,",
|
||||
" /**",
|
||||
" * @minItems 1 @maxItems 5",
|
||||
" * Each item: @minLength 1",
|
||||
" * @minItems 1",
|
||||
" * @maxItems 5",
|
||||
" */",
|
||||
" labels: Array<string>,",
|
||||
" /** Each value: @integer @minimum 0 */",
|
||||
" scores: {",
|
||||
" [key: string]: number,",
|
||||
" },",
|
||||
"}",
|
||||
].join("\n")
|
||||
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
|
||||
@@ -756,7 +522,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 summaries", async () => {
|
||||
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
|
||||
const { items } = await search("list issues repository")
|
||||
const item = items.find(({ path }) => path === "tools.github.list_issues")!
|
||||
expect(item.signature).toBe(
|
||||
@@ -766,9 +532,16 @@ 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>",
|
||||
|
||||
@@ -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.canonical ?? model.providerID,
|
||||
name: model.providerID,
|
||||
...(model.settings ?? {}),
|
||||
headers: model.headers,
|
||||
body: projected.body,
|
||||
@@ -249,7 +249,6 @@ 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,
|
||||
@@ -270,7 +269,6 @@ 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,
|
||||
@@ -303,11 +301,10 @@ export const locationLayer = Layer.effect(
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
const optionKey = providerOptionKey(packageName, providerID)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
const route: AnyRoute = {
|
||||
id: `ai-sdk:${packageName}`,
|
||||
provider: ProviderID.make(providerID),
|
||||
provider: ProviderID.make(info.providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
protocol: "ai-sdk",
|
||||
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
|
||||
@@ -334,13 +331,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) =>
|
||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
|
||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
}
|
||||
return LanguageModel.make({
|
||||
id: info.modelID ?? info.id,
|
||||
provider: providerID,
|
||||
provider: info.providerID,
|
||||
route,
|
||||
compatibility: info.compatibility,
|
||||
})
|
||||
|
||||
@@ -74,7 +74,6 @@ 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),
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
export * as CodeModeCatalog from "./catalog.js"
|
||||
|
||||
import type { Namespace } from "@opencode-ai/schema/tool"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.Literal("tool"),
|
||||
name: Schema.String,
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
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>
|
||||
readonly tools: ReadonlyArray<Tool>
|
||||
readonly namespaces?: ReadonlyMap<string, Namespace>
|
||||
}
|
||||
|
||||
const Listing = Schema.Struct({
|
||||
@@ -53,15 +47,14 @@ const INLINE_BUDGET = 2_000
|
||||
// considering shorter listings first until the inline budget is exhausted.
|
||||
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)]
|
||||
const namespaces = [...Map.groupBy(inventory.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 description = inventory.namespaces?.get(name)?.description
|
||||
const listings = namespaceEntries
|
||||
.map((entry) => {
|
||||
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
|
||||
@@ -133,35 +126,12 @@ export function summarize(inventory: Inventory, options: Options = {}): Summary
|
||||
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
|
||||
}))
|
||||
return {
|
||||
total: flattened.tools.length,
|
||||
total: inventory.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 =
|
||||
|
||||
@@ -40,14 +40,12 @@ type CollectedFiles = {
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
type Node<T> = {
|
||||
tool?: T
|
||||
type ToolNode = {
|
||||
tool?: Tool.Tool<never>
|
||||
namespace?: ToolNamespace
|
||||
readonly children: Map<string, Node<T>>
|
||||
readonly children: Map<string, ToolNode>
|
||||
}
|
||||
|
||||
type ToolNode = Node<Tool.Tool<never>>
|
||||
|
||||
type Tools = {
|
||||
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
|
||||
}
|
||||
@@ -164,43 +162,14 @@ export const catalog = (inventory: Inventory) => {
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
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),
|
||||
tools: runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((tool) => ({ ...tool, pinned: pinned.has(tool.path) })),
|
||||
...(inventory.namespaces === undefined ? {} : { namespaces: inventory.namespaces }),
|
||||
} 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(
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
@@ -222,9 +191,9 @@ function runtime(
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
function getNode(root: ToolNode, path: string) {
|
||||
return path.split(".").reduce((parent, name) => {
|
||||
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
|
||||
const child: ToolNode = parent.children.get(name) ?? { children: new Map() }
|
||||
parent.children.set(name, child)
|
||||
return child
|
||||
}, root)
|
||||
|
||||
@@ -44,17 +44,8 @@ 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)
|
||||
@@ -62,14 +53,7 @@ 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
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"])
|
||||
@@ -80,6 +81,7 @@ 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)
|
||||
@@ -111,7 +113,7 @@ const layer = Layer.effect(
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join(
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
@@ -126,5 +128,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node],
|
||||
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ 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") {}
|
||||
@@ -27,6 +28,7 @@ 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.canonical ?? resolved.providerID,
|
||||
providerID: resolved.providerID,
|
||||
})
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
@@ -161,7 +161,6 @@ 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),
|
||||
@@ -170,7 +169,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.canonical ?? resolved.providerID,
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Permission } from "./permission.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
plugins: readonly Generation[],
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Failure[],
|
||||
) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
@@ -38,8 +38,8 @@ export interface Interface {
|
||||
|
||||
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
|
||||
export type Generation = PluginDefinition & {
|
||||
readonly revision: string
|
||||
export type Versioned = PluginDefinition & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
readonly features?: Plugin.Features
|
||||
}
|
||||
@@ -52,11 +52,11 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let inventory: Plugin.Info[] = []
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
const load = Effect.fnUntraced(function* (plugin: Generation) {
|
||||
const load = Effect.fnUntraced(function* (plugin: Versioned) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
@@ -83,7 +83,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Generation[],
|
||||
plugins: readonly Versioned[],
|
||||
failures: readonly Failure[] = [],
|
||||
) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
@@ -99,14 +99,10 @@ const layer = Layer.effect(
|
||||
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
|
||||
return entry.plugin.id === definition?.id && entry.plugin.version === definition.version
|
||||
})
|
||||
) {
|
||||
for (const definition of definitions) {
|
||||
const entry = active.get(definition.id)
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
const nextInventory = [...definitions.map(activeInfo), ...failures]
|
||||
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, {})
|
||||
@@ -178,7 +174,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function activeInfo(plugin: Generation): Plugin.Info {
|
||||
function activeInfo(plugin: Versioned): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Experimental Browser Plugin
|
||||
|
||||
The server-side browser tool lives alongside the other built-in plugins. Its
|
||||
implementation uses only the public plugin API, public schemas, and Effect. The
|
||||
shared RPC contract is `@opencode-ai/schema/browser`; desktop clients do not import Core.
|
||||
|
||||
Disable it through normal plugin configuration:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": ["-opencode.browser"],
|
||||
}
|
||||
```
|
||||
|
||||
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
|
||||
session's location. Subscribe to server events before calling `attach`; wait for
|
||||
`server.connected`, then the matching `attached` control event. The `attach` call
|
||||
stays pending for the attachment lifetime. Abort it when its event stream ends or
|
||||
the desktop owner closes. Completing the attachment also ends that event consumer.
|
||||
|
||||
- `attach` holds one browser attachment per session until cancellation, plugin
|
||||
unload, session deletion, or session movement.
|
||||
- `state` reports the current page, or `null` when no page is open.
|
||||
- `result` completes a command with its request ID and outcome.
|
||||
- `control` events carry attachment confirmation, commands, and cancellation.
|
||||
|
||||
Control events use OpenCode's existing authenticated, server-wide event feed.
|
||||
Consumers filter by `connectionID`; this identifier is correlation, not private
|
||||
event delivery. State and results use RPC calls rather than broadcast events.
|
||||
|
||||
The plugin requests normal agent permissions before acting on a URL. Browser
|
||||
content is untrusted. Pages use the desktop's network, with no server-side tunnel.
|
||||
The desktop owns Chromium, page isolation, and native controls.
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Plugin, Session, Tool } from "@opencode-ai/plugin/effect"
|
||||
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
|
||||
import { Deferred, Effect, Encoding, Stream } from "effect"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
|
||||
type Attachment = {
|
||||
connectionID: string
|
||||
state: Browser.State | null
|
||||
closed: Deferred.Deferred<void>
|
||||
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.browser",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const browsers = new Map<Session.ID, Attachment>()
|
||||
let active = true
|
||||
const close = (sessionID: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(sessionID)
|
||||
if (!browser) return
|
||||
browsers.delete(sessionID)
|
||||
yield* Deferred.succeed(browser.closed, undefined)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => {
|
||||
active = false
|
||||
return Effect.forEach(browsers.keys(), close, { discard: true })
|
||||
})
|
||||
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
|
||||
.register(Browser.Definition, {
|
||||
attach: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: input.sessionID })
|
||||
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
|
||||
if (
|
||||
session.location.directory !== ctx.location.directory ||
|
||||
session.location.workspaceID !== ctx.location.workspaceID
|
||||
)
|
||||
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
|
||||
const browser = yield* Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
const closed = yield* Deferred.make<void>()
|
||||
if (!active || browsers.has(input.sessionID))
|
||||
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
const browser: Attachment = {
|
||||
connectionID: input.connectionID,
|
||||
state: null,
|
||||
closed,
|
||||
pending: new Map(),
|
||||
}
|
||||
browsers.set(input.sessionID, browser)
|
||||
return browser
|
||||
}),
|
||||
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
|
||||
)
|
||||
yield* rpc.events
|
||||
.emit("control", { type: "attached", connectionID: input.connectionID })
|
||||
.pipe(Effect.orDie)
|
||||
yield* Deferred.await(browser.closed)
|
||||
}).pipe(Effect.scoped),
|
||||
state: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(input.sessionID)
|
||||
if (!browser || browser.connectionID !== input.connectionID)
|
||||
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
browser.state = input.state
|
||||
}),
|
||||
result: (input, call) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(input.sessionID)
|
||||
if (!browser || browser.connectionID !== input.connectionID)
|
||||
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
||||
const pending = browser.pending.get(input.requestID)
|
||||
if (!pending) return
|
||||
if (input.outcome.type === "failure")
|
||||
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
|
||||
Effect.asVoid,
|
||||
)
|
||||
yield* Deferred.succeed(pending, input.outcome.result)
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name: "browser",
|
||||
input: Browser.Action,
|
||||
options: { codemode: false },
|
||||
description:
|
||||
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
|
||||
execute: (action, tool) =>
|
||||
Effect.gen(function* () {
|
||||
const browser = browsers.get(tool.sessionID)
|
||||
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
|
||||
if (action.type !== "open") {
|
||||
if (!browser.state) return yield* new Tool.Error({ message: "Open the browser first." })
|
||||
const url = action.type === "navigate" ? action.url : browser.state.url
|
||||
yield* ctx.permission
|
||||
.assert({
|
||||
action: "browser",
|
||||
resources: [url],
|
||||
metadata: { type: action.type, url },
|
||||
sessionID: tool.sessionID,
|
||||
agent: tool.agent,
|
||||
source: { type: "tool", messageID: tool.messageID, id: tool.id },
|
||||
})
|
||||
.pipe(Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })))
|
||||
}
|
||||
const requestID = crypto.randomUUID()
|
||||
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
|
||||
browser.pending.set(requestID, pending)
|
||||
const result = yield* rpc.events
|
||||
.emit("control", {
|
||||
type: "command",
|
||||
connectionID: browser.connectionID,
|
||||
requestID,
|
||||
command: { action, generation: browser.state?.generation ?? 0 },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
|
||||
Effect.andThen(Deferred.await(pending)),
|
||||
Effect.raceFirst(
|
||||
Deferred.await(browser.closed).pipe(
|
||||
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
|
||||
),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
rpc.events
|
||||
.emit("control", {
|
||||
type: "cancel",
|
||||
connectionID: browser.connectionID,
|
||||
requestID,
|
||||
})
|
||||
.pipe(Effect.ignore),
|
||||
),
|
||||
Effect.timeoutOrElse({
|
||||
duration: "30 seconds",
|
||||
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
|
||||
)
|
||||
return render(result)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (!browsers.has(event.sessionID)) delete event.tools.browser
|
||||
}),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
|
||||
Stream.runForEach((event) => close(event.data.sessionID)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function render(result: Browser.Result): Tool.Result {
|
||||
if (result.type === "screenshot")
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "Untrusted browser screenshot." },
|
||||
{
|
||||
type: "file",
|
||||
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
|
||||
mime: "image/png",
|
||||
name: "browser-screenshot.png",
|
||||
},
|
||||
],
|
||||
metadata: { url: result.state.url },
|
||||
}
|
||||
const content = JSON.stringify(result)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e")
|
||||
.replaceAll("&", "\\u0026")
|
||||
return {
|
||||
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
|
||||
metadata: { url: result.state.url },
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
assert: permission.assert,
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
get: (input) =>
|
||||
|
||||
@@ -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 { Generation } from "../plugin.js"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
|
||||
/**
|
||||
* Holds the plugins one instance is born with. Unlike the host-global
|
||||
@@ -13,13 +13,16 @@ import type { Generation } from "../plugin.js"
|
||||
* for the instance's lifetime; runtime dynamism lives inside plugins through
|
||||
* the container transform/reload APIs.
|
||||
*
|
||||
* Config plugin operations may disable instance plugins by id, matching
|
||||
* `SdkPlugins` behavior.
|
||||
* 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.
|
||||
*/
|
||||
export type List = readonly Plugin[]
|
||||
|
||||
export interface Interface {
|
||||
readonly all: () => readonly Generation[]
|
||||
readonly all: () => readonly Versioned[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
|
||||
@@ -30,8 +33,8 @@ export const node = makeLocationNode({
|
||||
deps: [],
|
||||
})
|
||||
|
||||
// The constant revision is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, revision) pair as the same plugin across activations, which
|
||||
// The constant version is load-bearing: the plugin registry treats an
|
||||
// unchanged (id, version) 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.
|
||||
@@ -41,7 +44,7 @@ export function bound(plugins: List) {
|
||||
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
|
||||
}
|
||||
const stamped = plugins.map(
|
||||
(plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }),
|
||||
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
|
||||
)
|
||||
return Layer.succeed(Service, Service.of({ all: () => stamped }))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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"
|
||||
@@ -74,6 +75,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
|
||||
import { WellKnown } from "../wellknown.js"
|
||||
import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "./browser/index.js"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -96,6 +98,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
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
|
||||
@@ -139,6 +142,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
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),
|
||||
@@ -189,6 +193,7 @@ export const requirements = LayerNode.group([
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
@@ -230,6 +235,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
@@ -283,6 +289,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
plugins.map(
|
||||
(plugin): Plugin => ({
|
||||
id: plugin.id,
|
||||
vcs: plugin.vcs,
|
||||
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,17 +8,23 @@ import { readdir } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import type { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import type { Generation } from "../plugin.js"
|
||||
import type { Versioned } 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({
|
||||
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",
|
||||
),
|
||||
@@ -28,17 +34,13 @@ const Definition = Schema.Struct({
|
||||
|
||||
export const load = Effect.fn("PluginModule.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
options?: { readonly install?: boolean },
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed: Npm.EntryPoint = local
|
||||
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
|
||||
: options?.install === false
|
||||
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const installed = local
|
||||
? { entrypoint: pathToFileURL(operation.target).href }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
@@ -61,16 +63,13 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
return {
|
||||
id: plugin.id,
|
||||
features,
|
||||
revision: JSON.stringify([operation, installed.revision]),
|
||||
vcs: plugin.vcs,
|
||||
version: JSON.stringify(operation),
|
||||
source: path.isAbsolute(operation.target)
|
||||
? { type: "local" as const, path: operation.target }
|
||||
: {
|
||||
type: "package" as const,
|
||||
target: operation.target,
|
||||
...(installed.version ? { version: installed.version } : {}),
|
||||
},
|
||||
: { type: "package" as const, package: operation.target },
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Generation
|
||||
} satisfies Versioned
|
||||
})
|
||||
|
||||
function localFeatures(entrypoint: string) {
|
||||
|
||||
@@ -6,14 +6,17 @@ 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 { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
||||
import { ConfigProviderV1 } from "../../v1/config/provider.js"
|
||||
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({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
|
||||
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
@@ -86,7 +89,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 RemoteResponse.Type.providers | undefined
|
||||
let providers: typeof ConfigV1.Info.Type.provider | undefined
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
@@ -114,73 +117,59 @@ 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.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)
|
||||
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 }
|
||||
})
|
||||
|
||||
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) => {
|
||||
Object.assign(model, structuredClone(base ?? model))
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.family !== undefined) model.family = Model.Family.make(config.family)
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
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.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.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 }
|
||||
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.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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -219,7 +208,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/v2/config`).pipe(
|
||||
HttpClientRequest.get(`${server}/api/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
@@ -230,29 +219,14 @@ 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.providers),
|
||||
Effect.map((remote) => remote.config.provider),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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 withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
|
||||
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
|
||||
}
|
||||
|
||||
function normalizeServer(input: unknown) {
|
||||
@@ -268,6 +242,30 @@ 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* () {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { Generation } from "../plugin.js"
|
||||
import type { Versioned } from "../plugin.js"
|
||||
|
||||
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
readonly all: () => readonly Generation[]
|
||||
readonly all: () => readonly Versioned[]
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
@@ -30,12 +30,12 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const plugins = new Map<string, Generation>()
|
||||
const plugins = new Map<string, Versioned>()
|
||||
let revision = 0
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
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 { Config } from "../config.js"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import os from "os"
|
||||
import opencodeContent from "./skill/opencode.md" with { type: "text" }
|
||||
import reportContent from "./skill/report.md" with { type: "text" }
|
||||
@@ -69,11 +68,9 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||
})
|
||||
|
||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
||||
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))
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
return (yield* sources.operations())
|
||||
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@ import { Context, Effect } from "effect"
|
||||
* imports: the supervisor reaches PluginRuntime, which depends on Session.
|
||||
*/
|
||||
export interface Interface {
|
||||
/**
|
||||
* Wait for the plugin generation to settle. Use this rarely: blocking reads,
|
||||
* UI startup, or other unrelated work on plugin boot should be avoided.
|
||||
*/
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
||||
@@ -14,20 +14,17 @@ 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.Generation[],
|
||||
post: readonly Plugin.Generation[],
|
||||
pre: readonly Plugin.Versioned[],
|
||||
post: readonly Plugin.Versioned[],
|
||||
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.Generation>()
|
||||
const pending = new Set<string>()
|
||||
const packages = new Map<string, Plugin.Versioned>()
|
||||
const failures = new Map<
|
||||
string,
|
||||
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
@@ -54,17 +51,13 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* PluginModule.load(operation, { install }).pipe(
|
||||
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) }),
|
||||
),
|
||||
),
|
||||
)
|
||||
if ("pending" in plugin) {
|
||||
pending.add(operation.target)
|
||||
continue
|
||||
}
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
@@ -87,7 +80,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
pending: [...pending],
|
||||
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
|
||||
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -99,74 +94,45 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const updates = yield* PluginUpdate.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let packages = new Set<string>()
|
||||
let outdated = new Set<string>()
|
||||
let generation = 0
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
const current = ++generation
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// 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, revision: "internal", source: { type: "builtin" as const } })),
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
...instance.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
revision: "internal",
|
||||
version: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const operations = yield* sources.operations()
|
||||
// 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 }),
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
const reloads = Stream.merge(
|
||||
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
|
||||
updates.changes().pipe(
|
||||
Stream.filter((target) => packages.has(target)),
|
||||
Stream.tap((target) => Effect.sync(() => outdated.delete(target))),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
@@ -176,7 +142,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
@@ -188,7 +154,6 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("24 hours").pipe(Effect.andThen(activate()), Effect.forever, Effect.forkScoped)
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
@@ -198,7 +163,6 @@ const nodeDeps = [
|
||||
SdkPlugins.node,
|
||||
InstancePlugins.node,
|
||||
ConfigPluginSource.node,
|
||||
PluginUpdate.node,
|
||||
Bus.node,
|
||||
Npm.node,
|
||||
PluginInternal.requirements,
|
||||
@@ -206,7 +170,7 @@ const nodeDeps = [
|
||||
|
||||
function pluginSource(target: string): Plugin.Source {
|
||||
if (path.isAbsolute(target)) return { type: "local", path: target }
|
||||
return { type: "package", target }
|
||||
return { type: "package", package: target }
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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) => Effect.Effect<boolean>
|
||||
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
|
||||
readonly changes: () => Stream.Stream<string>
|
||||
}
|
||||
|
||||
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<string>()
|
||||
|
||||
return Service.of({
|
||||
check: (target) =>
|
||||
locks.withLock(target)(
|
||||
Effect.gen(function* () {
|
||||
const checkedAt = yield* Clock.currentTimeMillis
|
||||
const current = status.get(target)
|
||||
if (current && checkedAt - current.checkedAt < interval) return current.outdated
|
||||
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 })
|
||||
return value
|
||||
}),
|
||||
),
|
||||
update: (target) =>
|
||||
npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.tap(() => PubSub.publish(changes, target)),
|
||||
Effect.asVoid,
|
||||
),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
|
||||
@@ -20,6 +20,7 @@ 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,6 +24,7 @@ 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,6 +13,7 @@ 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"
|
||||
@@ -43,6 +44,7 @@ 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.
|
||||
@@ -96,6 +98,7 @@ 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
|
||||
@@ -169,7 +172,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: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
|
||||
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
if (!found[0]) return false
|
||||
return (yield* fs.resolve(path.dirname(found[0]))) === directory
|
||||
@@ -315,9 +318,10 @@ 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),
|
||||
@@ -326,7 +330,7 @@ const layer = Layer.effect(
|
||||
native && path.basename(native) === ".git"
|
||||
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
|
||||
: undefined
|
||||
if (repo) {
|
||||
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
|
||||
const canonical =
|
||||
@@ -342,14 +346,27 @@ 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) {
|
||||
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -368,5 +385,5 @@ const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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],
|
||||
})
|
||||
@@ -82,7 +82,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
const selected = () => {
|
||||
const value = state.get()
|
||||
const id = value.selection ?? vcs?.type
|
||||
const id = value.selection ?? location.vcsBackend ?? vcs?.type
|
||||
return id ? value.providers.get(id) : undefined
|
||||
}
|
||||
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
|
||||
|
||||
@@ -87,43 +87,6 @@ it.effect("keys language models by package and flattened overlays", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses canonical names and metadata without merging connection cache partitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
const loaded: string[] = []
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
loaded.push(`${event.model.providerID}:${event.options.name}`)
|
||||
event.sdk = createOpenAICompatible({
|
||||
...event.options,
|
||||
name: String(event.options.name),
|
||||
baseURL: String(event.options.baseURL),
|
||||
})
|
||||
})
|
||||
const input = {
|
||||
...model("@ai-sdk/openai-compatible", { baseURL: "https://proxy.example/v1", reasoningEffort: "high" }),
|
||||
providerID: Provider.ID.make("work"),
|
||||
canonical: Provider.ID.openai,
|
||||
}
|
||||
const first = yield* aisdk.language(input)
|
||||
const second = yield* aisdk.language({ ...input, providerID: Provider.ID.make("personal") })
|
||||
const plain = yield* aisdk.language({ ...input, canonical: undefined })
|
||||
|
||||
expect(yield* aisdk.language(input)).toBe(first)
|
||||
expect(first).not.toBe(second)
|
||||
expect(first).not.toBe(plain)
|
||||
expect(first).toMatchObject({ modelId: "api-model", provider: "openai.chat" })
|
||||
expect(plain.provider).toBe("work.chat")
|
||||
expect(loaded).toEqual(["work:openai", "personal:openai", "work:work"])
|
||||
|
||||
const resolved = yield* aisdk.model(input)
|
||||
expect(resolved).toMatchObject({ id: "api-model", provider: "openai" })
|
||||
expect(resolved.route).toMatchObject({ provider: "openai", providerMetadataKey: "openai" })
|
||||
expect(resolved.route.model({ id: "another-model" })).toMatchObject({ provider: "openai" })
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects request settings, headers, and body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -10,8 +10,7 @@ describe("CodeMode", () => {
|
||||
it.effect("owns registrations, execute, and catalog materialization", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tool.Service
|
||||
yield* tools.transform((draft) => {
|
||||
draft.namespace({ name: "empty", description: "No tools registered yet" })
|
||||
yield* tools.transform((draft) =>
|
||||
draft.add({
|
||||
name: "echo",
|
||||
description: "Echo text",
|
||||
@@ -19,27 +18,21 @@ describe("CodeMode", () => {
|
||||
output: Schema.String,
|
||||
options: { pinned: true },
|
||||
execute: ({ text }) => Effect.succeed({ output: text }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const snapshot = yield* tools.snapshot()
|
||||
expect(snapshot.definitions.some((tool) => tool.name === "execute")).toBe(true)
|
||||
expect(snapshot.codeModeCatalog).toStrictEqual({
|
||||
tools: [
|
||||
{
|
||||
type: "tool",
|
||||
name: "echo",
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
pinned: true,
|
||||
},
|
||||
{
|
||||
type: "namespace",
|
||||
name: "empty",
|
||||
description: "No tools registered yet",
|
||||
tools: [],
|
||||
},
|
||||
],
|
||||
namespaces: new Map(),
|
||||
})
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Tool => ({
|
||||
type: "tool",
|
||||
name: path,
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
pinned,
|
||||
@@ -16,12 +15,12 @@ const lookup = entry(
|
||||
"tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
|
||||
)
|
||||
|
||||
const render = (tools: CodeModeCatalog.Inventory["tools"], budget?: number) =>
|
||||
const render = (tools: ReadonlyArray<CodeModeCatalog.Tool>, budget?: number) =>
|
||||
CodeModeInstructions.render(CodeModeCatalog.summarize({ tools }, budget === undefined ? {} : { budget }))
|
||||
|
||||
const update = (
|
||||
previous: CodeModeCatalog.Inventory["tools"],
|
||||
current: CodeModeCatalog.Inventory["tools"],
|
||||
previous: ReadonlyArray<CodeModeCatalog.Tool>,
|
||||
current: ReadonlyArray<CodeModeCatalog.Tool>,
|
||||
budget?: number,
|
||||
) =>
|
||||
CodeModeInstructions.update(
|
||||
@@ -104,10 +103,10 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
const listingCost = Math.round(` - ${tool.signature} // One`.length / 4)
|
||||
const namespaceCost = Math.round(CodeModeCatalog.namespaceLine({ name: "alpha", count: 1, entries: [] }).length / 4)
|
||||
const description = "A namespace description that stays visible beyond the available tool budget"
|
||||
const namespace = { type: "namespace" as const, name: "alpha", description, tools: [tool] }
|
||||
const namespaces = new Map([["alpha", { name: "alpha", description }]])
|
||||
|
||||
expect(CodeModeCatalog.summarize({ tools: [tool] }, { budget: namespaceCost + listingCost }).shown).toBe(1)
|
||||
const catalog = CodeModeCatalog.summarize({ tools: [namespace] }, { budget: namespaceCost + listingCost })
|
||||
const catalog = CodeModeCatalog.summarize({ tools: [tool], namespaces }, { budget: namespaceCost + listingCost })
|
||||
expect(catalog.shown).toBe(0)
|
||||
expect(catalog.namespaces[0]?.description).toBe(description)
|
||||
expect(CodeModeInstructions.render(catalog)).toContain(`- alpha (1 tool, none shown) // ${description}`)
|
||||
@@ -137,8 +136,8 @@ describe("CodeModeInstructions.render", () => {
|
||||
)
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" /** @integer @exclusiveMinimum 0 */\n limit?: number,")
|
||||
expect(partial).toContain(" /** @integer @minimum 0 */\n offset?: number,")
|
||||
expect(partial).toContain(" /**\n * @integer\n * @exclusiveMinimum 0\n */\n limit?: number,")
|
||||
expect(partial).toContain(" /**\n * @integer\n * @minimum 0\n */\n offset?: number,")
|
||||
expect(partial).not.toContain("tools.orders.lookup(input:")
|
||||
})
|
||||
|
||||
@@ -210,10 +209,12 @@ describe("CodeModeInstructions.update", () => {
|
||||
|
||||
test("restates namespace descriptions when they change", () => {
|
||||
const previous = CodeModeCatalog.summarize({
|
||||
tools: [{ type: "namespace", name: "notes", description: "Old description", tools: [echo] }],
|
||||
tools: [echo],
|
||||
namespaces: new Map([["notes", { name: "notes", description: "Old description" }]]),
|
||||
})
|
||||
const current = CodeModeCatalog.summarize({
|
||||
tools: [{ type: "namespace", name: "notes", description: "New description", tools: [echo] }],
|
||||
tools: [echo],
|
||||
namespaces: new Map([["notes", { name: "notes", description: "New description" }]]),
|
||||
})
|
||||
const text = CodeModeInstructions.update(previous, current)
|
||||
expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
|
||||
|
||||
@@ -10,15 +10,13 @@ import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const echo: CodeModeCatalog.Tool = {
|
||||
type: "tool",
|
||||
name: "notes.echo",
|
||||
path: "notes.echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
}
|
||||
|
||||
const lookup: CodeModeCatalog.Tool = {
|
||||
type: "tool",
|
||||
name: "orders.lookup",
|
||||
path: "orders.lookup",
|
||||
description: "Look up an order",
|
||||
signature: "tools.orders.lookup(input: {\n id: string,\n}): Promise<unknown>",
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Layer, Logger, Option, Schedule, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -37,79 +37,38 @@ const staticIt = testEffect(
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
]),
|
||||
)
|
||||
const outdatedNpm = makeGlobalNode({
|
||||
const refreshNpm = makeGlobalNode({
|
||||
service: Npm.Service,
|
||||
layer: Layer.effect(
|
||||
Npm.Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "outdated-plugin")
|
||||
let version = "1.0.0"
|
||||
const installed = () => ({
|
||||
directory,
|
||||
entrypoint: pathToFileURL(path.join(directory, "index.js")).href,
|
||||
version,
|
||||
revision: version,
|
||||
})
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Bun.write(path.join(directory, "index.js"), 'export default { id: "outdated-plugin", setup() {} }')
|
||||
})
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.sync(installed),
|
||||
resolve: () => Effect.sync(installed),
|
||||
check: () => Effect.sync(() => version === "1.0.0"),
|
||||
update: () => Effect.sync(() => (version = "1.1.0")).pipe(Effect.map(installed)),
|
||||
add: (_pkg, options) =>
|
||||
options?.refresh
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
|
||||
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
|
||||
return installed
|
||||
})
|
||||
: Effect.succeed(installed),
|
||||
resolve: () => Effect.succeed(installed),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Global.node],
|
||||
})
|
||||
const updateIt = testEffect(
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[Global.node.replace(tempGlobalLayer), Npm.node.replace(outdatedNpm)],
|
||||
),
|
||||
)
|
||||
const coldNpm = makeGlobalNode({
|
||||
service: Npm.Service,
|
||||
layer: Layer.effect(
|
||||
Npm.Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "cold-plugin")
|
||||
const started = path.join(directory, "started")
|
||||
const release = path.join(directory, "release")
|
||||
const entry = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href, revision: "1" }
|
||||
let installed = false
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Bun.write(path.join(directory, "index.js"), 'export default { id: "cold-plugin", setup() {} }')
|
||||
})
|
||||
return Npm.Service.of({
|
||||
add: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(started, ""))
|
||||
yield* waitForFile(release).pipe(Effect.orDie)
|
||||
installed = true
|
||||
return entry
|
||||
}),
|
||||
resolve: () => Effect.sync(() => (installed ? entry : { directory })),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed(entry),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Global.node],
|
||||
})
|
||||
const coldIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[Global.node.replace(tempGlobalLayer), Npm.node.replace(coldNpm)],
|
||||
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
|
||||
),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
withLocation(
|
||||
@@ -504,40 +463,46 @@ describe("PluginSupervisor config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
updateIt.live("marks active package plugins as outdated after a background check", () =>
|
||||
withLocation(
|
||||
{ plugins: ["outdated-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const source = yield* Effect.suspend(() => plugins.list()).pipe(
|
||||
Effect.map((items) => items.find((item) => item.id === "outdated-plugin")?.source),
|
||||
Effect.filterOrFail((source) => source?.type === "package" && source.outdated === true),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("2 seconds"),
|
||||
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const activated = path.join(directory, "activated")
|
||||
const release = path.join(directory, "release")
|
||||
const refreshed = path.join(directory, "refresh-requested")
|
||||
const refreshRelease = path.join(directory, "refresh-release")
|
||||
const refreshFinished = path.join(directory, "refresh-finished")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directory, "index.js"),
|
||||
`export default {
|
||||
id: "background-refresh-plugin",
|
||||
async setup() {
|
||||
await Bun.write(${JSON.stringify(activated)}, "")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
|
||||
},
|
||||
}`,
|
||||
)
|
||||
expect(source).toEqual({ type: "package", target: "outdated-plugin", version: "1.0.0", outdated: true })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
coldIt.live("activates available plugins before a missing package finishes installing", () =>
|
||||
withLocation(
|
||||
{ plugins: ["cold-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
yield* waitForFile(path.join(global.tmp, "cold-plugin", "started"))
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("opencode.provider.openai")
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
expect(Option.isNone(yield* supervisor.flush.pipe(Effect.timeoutOption("20 millis")))).toBeTrue()
|
||||
yield* Effect.promise(() => Bun.write(path.join(global.tmp, "cold-plugin", "release"), ""))
|
||||
yield* supervisor.flush.pipe(Effect.timeout("2 seconds"))
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("cold-plugin")
|
||||
}),
|
||||
),
|
||||
yield* withLocation(
|
||||
{ plugins: ["background-refresh-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* waitForFile(activated)
|
||||
yield* Effect.sleep("100 millis")
|
||||
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
|
||||
yield* Effect.promise(() => Bun.write(release, ""))
|
||||
yield* waitForFile(refreshed)
|
||||
yield* ready().pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
|
||||
yield* waitForFile(refreshFinished)
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -405,7 +405,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
canonical: "anthropic",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
package: "native",
|
||||
headers: { first: "first", shared: "first" },
|
||||
@@ -442,7 +441,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:custom-sdk",
|
||||
canonical: "anthropic",
|
||||
settings: { baseURL: "https://example.test" },
|
||||
headers: { last: "last", shared: "last" },
|
||||
models: {
|
||||
@@ -480,23 +478,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
]
|
||||
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.anthropic, (provider) => {
|
||||
provider.package = "aisdk:@ai-sdk/anthropic"
|
||||
})
|
||||
draft.model.update(Provider.ID.anthropic, modelID, (model) => {
|
||||
model.variants = [{ id: Model.VariantID.make("fast"), settings: { effort: "high" } }]
|
||||
})
|
||||
})
|
||||
yield* addPlugin(entries)
|
||||
|
||||
const provider = required(yield* catalog.provider.get(providerID))
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect((yield* catalog.model.default())?.id).toBe(Model.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.canonical).toBe(Provider.ID.anthropic)
|
||||
expect(model.canonical).toBe(Provider.ID.anthropic)
|
||||
expect(model.providerID).toBe(providerID)
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
@@ -504,7 +491,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.activation).toBe("enabled")
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(model.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.id).toBe(modelID)
|
||||
@@ -536,7 +522,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
Model.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants?.[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants?.[0]?.settings).toEqual({ effort: "high" })
|
||||
expect(model.variants?.[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -30,9 +30,7 @@ export const promptLocationNode = makeGlobalNode({
|
||||
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
|
||||
}),
|
||||
Layer.succeed(FSUtil.Service, fs),
|
||||
Layer.succeed(PluginSupervisor.Service, {
|
||||
flush: Effect.void,
|
||||
}),
|
||||
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
|
||||
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import type { Permission } from "@opencode-ai/core/permission"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
@@ -17,9 +16,6 @@ export const toolIdentity = {
|
||||
export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) =>
|
||||
registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions))
|
||||
|
||||
export const codeModeListings = (catalog: CodeModeCatalog.Inventory) =>
|
||||
CodeModeCatalog.summarize(catalog, { budget: Infinity }).namespaces.flatMap((namespace) => namespace.entries)
|
||||
|
||||
export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect<void, Error> {
|
||||
return Effect.gen(function* () {
|
||||
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
|
||||
@@ -39,8 +35,7 @@ export function waitForCodeModeTool(
|
||||
): Effect.Effect<Tool.Snapshot, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const toolSet = yield* registry.snapshot()
|
||||
if (toolSet.codeModeCatalog && codeModeListings(toolSet.codeModeCatalog).some((tool) => tool.path === path))
|
||||
return toolSet
|
||||
if (toolSet.codeModeCatalog?.tools.some((tool) => tool.path === path)) return toolSet
|
||||
if (remaining === 0) {
|
||||
return yield* Effect.fail(new Error(`Timed out waiting for Code Mode tool: ${path}`))
|
||||
}
|
||||
|
||||
@@ -940,7 +940,7 @@ describe("LocationServiceMap", () => {
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
yield* plugins.activate([{ ...reviewer, revision: "1" }])
|
||||
yield* plugins.activate([{ ...reviewer, version: "1" }])
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({
|
||||
|
||||
@@ -162,6 +162,6 @@ describe("MCP OAuth", () => {
|
||||
})
|
||||
|
||||
test("rejects an invalid redirect URL", async () => {
|
||||
await expect(authorize("not a URL")).rejects.toThrow(TypeError)
|
||||
await expect(authorize("not a URL")).rejects.toThrow("cannot be parsed as a URL")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
@@ -1764,7 +1764,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
"direct_media",
|
||||
"execute",
|
||||
])
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).toContain(
|
||||
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).toContain(
|
||||
"ok: boolean",
|
||||
)
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
@@ -1784,7 +1784,7 @@ it.effect("forwards the invoking session through direct and Code Mode MCP tools"
|
||||
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
|
||||
"properties.sessionID",
|
||||
)
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).find((tool) => tool.path === "demo.search")?.line).not.toContain(
|
||||
expect(toolSet.codeModeCatalog?.tools.find((tool) => tool.path === "demo.search")?.signature).not.toContain(
|
||||
"sessionID",
|
||||
)
|
||||
|
||||
@@ -1830,7 +1830,7 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_content_only"),
|
||||
@@ -1916,7 +1916,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const fiber = yield* toolSet
|
||||
.execute({
|
||||
@@ -1960,7 +1960,7 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(codeModeListings(toolSet.codeModeCatalog!).some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
expect(toolSet.codeModeCatalog?.tools.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_blocked"),
|
||||
|
||||
@@ -16,7 +16,6 @@ import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
readonly providerID?: Provider.ID
|
||||
readonly canonical?: Provider.ID
|
||||
readonly modelID?: string
|
||||
readonly compatibility?: Compatibility
|
||||
readonly settings?: Info["settings"]
|
||||
@@ -31,7 +30,6 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
id: ID.make("test-model"),
|
||||
modelID: ID.make(options.modelID ?? "api-test-model"),
|
||||
providerID: options.providerID ?? Provider.ID.make("test-provider"),
|
||||
canonical: options.canonical,
|
||||
name: "Test model",
|
||||
compatibility: options.compatibility,
|
||||
package: packageName,
|
||||
@@ -285,7 +283,6 @@ describe("ModelResolver", () => {
|
||||
it.effect("uses no native API-key auth for explicitly enabled providers without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
canonical: Provider.ID.google,
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
@@ -325,10 +322,7 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: (id) => {
|
||||
expect(id).toBe(Integration.ID.make("gateway"))
|
||||
return Effect.undefined
|
||||
},
|
||||
active: () => Effect.undefined,
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
activate: () => Effect.die("unused"),
|
||||
@@ -375,8 +369,6 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
expect(resolved.limit).toEqual(selection.limit)
|
||||
expect(resolved.ref.providerID).toBe(selection.providerID)
|
||||
expect(String(resolved.model.provider)).toBe(selection.canonical ?? selection.providerID)
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
@@ -413,7 +405,6 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
canonical: Provider.ID.make("deepseek"),
|
||||
compatibility: {
|
||||
reasoningField: "vendor_reasoning",
|
||||
requireReasoning: true,
|
||||
@@ -442,8 +433,6 @@ describe("ModelResolver", () => {
|
||||
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(String(resolved.provider)).toBe("deepseek")
|
||||
expect(resolved.route.providerMetadataKey).toBe("deepseek")
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
expect(resolved.compatibility?.requireReasoning).toBe(true)
|
||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
||||
|
||||
+32
-141
@@ -55,58 +55,6 @@ async function createGitFixture(directory: string) {
|
||||
return { repository, commit }
|
||||
}
|
||||
|
||||
async function createRegistryFixture(directory: string) {
|
||||
const tarballs = new Map<string, Uint8Array>()
|
||||
for (const version of ["1.0.0", "1.1.0"]) {
|
||||
const root = path.join(directory, version)
|
||||
await fs.mkdir(path.join(root, "package"), { recursive: true })
|
||||
await writePackage(path.join(root, "package"), {
|
||||
name: "@fixture/registry-plugin",
|
||||
version,
|
||||
exports: "./index.js",
|
||||
})
|
||||
await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`)
|
||||
await Bun.$`tar -czf package.tgz package`.cwd(root)
|
||||
tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes())
|
||||
}
|
||||
const state = { latest: "1.0.0" }
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (decodeURIComponent(url.pathname) === "/@fixture/registry-plugin")
|
||||
return Response.json({
|
||||
name: "@fixture/registry-plugin",
|
||||
"dist-tags": { latest: state.latest },
|
||||
versions: Object.fromEntries(
|
||||
[...tarballs.keys()].map((version) => [
|
||||
version,
|
||||
{ name: "@fixture/registry-plugin", version, dist: { tarball: `${url.origin}/${version}.tgz` } },
|
||||
]),
|
||||
),
|
||||
})
|
||||
const tarball = tarballs.get(url.pathname.slice(1).replace(".tgz", ""))
|
||||
return tarball ? new Response(tarball) : new Response("missing", { status: 404 })
|
||||
},
|
||||
})
|
||||
return {
|
||||
state,
|
||||
async configure(cache: string, spec: string) {
|
||||
const root = path.join(cache, "npm", await Npm.cacheKey(spec))
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(root, ".npmrc"),
|
||||
`@fixture:registry=${server.url}\ncache=${path.join(directory, "npm-cache")}\nfetch-retries=0\naudit=false\n`,
|
||||
)
|
||||
return root
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -150,12 +98,10 @@ describe("Npm.isInstallablePackage", () => {
|
||||
})
|
||||
|
||||
describe("Npm.cacheKey", () => {
|
||||
test("canonicalizes registry keys and hashes Git specs", async () => {
|
||||
test("preserves registry keys and hashes Git specs", async () => {
|
||||
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
|
||||
expect(await Npm.cacheKey("plugin")).toBe(Npm.sanitize("plugin@latest"))
|
||||
expect(await Npm.cacheKey("@opencode/acme")).toBe(Npm.sanitize("@opencode/acme@latest"))
|
||||
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
|
||||
expect(await Npm.cacheKey(spec)).toMatch(/^git-plugin-[a-f0-9]{12}$/)
|
||||
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
|
||||
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
|
||||
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
|
||||
})
|
||||
@@ -168,9 +114,8 @@ describe("Npm.add", () => {
|
||||
const directory = path.join(
|
||||
tmp.path,
|
||||
"cache",
|
||||
"npm",
|
||||
await Npm.cacheKey(spec),
|
||||
"1000",
|
||||
"packages",
|
||||
Npm.sanitize(spec),
|
||||
"node_modules",
|
||||
"@fixture",
|
||||
"provider",
|
||||
@@ -191,7 +136,7 @@ describe("Npm.add", () => {
|
||||
test("falls back to the original spec when parsing fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spec = "fixture provider"
|
||||
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
|
||||
const directory = path.join(tmp.path, "cache", "packages", Npm.sanitize(spec), "node_modules", spec)
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, { name: spec, exports: "./index.js" })
|
||||
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
|
||||
@@ -219,7 +164,7 @@ describe("Npm.add", () => {
|
||||
await Bun.write(path.join(tmp.path, "fixture-provider", "tui.js"), "export const tui = true\n")
|
||||
|
||||
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
|
||||
await fs.mkdir(path.join(tmp.path, "cache", "npm", Npm.sanitize(spec)), { recursive: true })
|
||||
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
|
||||
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
@@ -252,15 +197,13 @@ describe("Npm.add", () => {
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
|
||||
expect(entries.added.version).toBe(fixture.commit)
|
||||
expect(entries.added.entrypoint).toEndWith("/index.js")
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec)))
|
||||
expect(entries.added.directory).toContain("node_modules")
|
||||
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -280,8 +223,8 @@ describe("Npm.add", () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
// Several real Git installs and updates exceed Bun's default timeout on Windows.
|
||||
test("checks and updates mutable Git packages without changing pinned installs", async () => {
|
||||
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
|
||||
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
@@ -289,38 +232,37 @@ describe("Npm.add", () => {
|
||||
const mutable = `git+${repository}#fixture-branch`
|
||||
const pinned = `git+${repository}#${fixture.commit}`
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const first = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable)
|
||||
const pinnedEntry = yield* npm.add(pinned)
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
await Bun.$`git -C ${fixture.repository} add .`
|
||||
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
|
||||
})
|
||||
const before = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
|
||||
const outdated = yield* npm.check(mutable)
|
||||
const pinnedOutdated = yield* npm.check(pinned)
|
||||
const unchanged = yield* Effect.promise(() => Bun.file(path.join(mutableEntry.directory, "index.js")).text())
|
||||
const updated = yield* npm.update(mutable)
|
||||
const pinnedUpdated = yield* npm.update(pinned)
|
||||
yield* npm.add(mutable, { refresh: true })
|
||||
return { mutable: mutableEntry, pinned: pinnedEntry }
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(first.mutable.directory, "index.js")).text()).toContain("root: true")
|
||||
expect(await Bun.file(path.join(first.pinned.directory, "index.js")).text()).toContain("root: true")
|
||||
|
||||
const second = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
before,
|
||||
outdated,
|
||||
pinnedOutdated,
|
||||
unchanged,
|
||||
updated: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
|
||||
pinned: yield* Effect.promise(() => Bun.file(path.join(pinnedUpdated.directory, "index.js")).text()),
|
||||
current: yield* npm.check(mutable),
|
||||
mutable: yield* npm.add(mutable, { refresh: true }),
|
||||
pinned: yield* npm.add(pinned, { refresh: true }),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(result.before).toContain("root: true")
|
||||
expect(result.outdated).toBeTrue()
|
||||
expect(result.pinnedOutdated).toBeFalse()
|
||||
expect(result.unchanged).toContain("root: true")
|
||||
expect(result.updated).toContain('root: "second"')
|
||||
expect(result.pinned).toContain("root: true")
|
||||
expect(result.current).toBeFalse()
|
||||
expect(await Bun.file(path.join(second.mutable.directory, "index.js")).text()).toContain('root: "second"')
|
||||
expect(await Bun.file(path.join(second.pinned.directory, "index.js")).text()).toContain("root: true")
|
||||
|
||||
await fs.rename(fixture.repository, `${fixture.repository}-offline`)
|
||||
const offline = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -329,7 +271,7 @@ describe("Npm.resolve", () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
|
||||
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
@@ -349,56 +291,5 @@ describe("Npm.resolve", () => {
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
expect(resolved.version).toBe("1.0.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.check and Npm.update", () => {
|
||||
test("checks registry targets without mutation and explicitly updates mutable targets", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using registry = await createRegistryFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const mutable = "@fixture/registry-plugin@latest"
|
||||
const pinned = "@fixture/registry-plugin@1.0.0"
|
||||
const root = await registry.configure(cache, mutable)
|
||||
await registry.configure(cache, pinned)
|
||||
|
||||
const result = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(mutable)
|
||||
yield* npm.add(pinned)
|
||||
const current = yield* npm.check(mutable)
|
||||
registry.state.latest = "1.1.0"
|
||||
const outdated = yield* npm.check(mutable)
|
||||
const pinnedOutdated = yield* npm.check(pinned)
|
||||
const before = yield* Effect.promise(() => Bun.file(path.join(installed.directory, "index.js")).text())
|
||||
yield* Effect.promise(() => Promise.all([fs.mkdir(path.join(root, "1")), fs.mkdir(path.join(root, "2"))]))
|
||||
const updated = yield* npm.update(mutable)
|
||||
const unchanged = yield* npm.update(mutable)
|
||||
return {
|
||||
current,
|
||||
outdated,
|
||||
pinnedOutdated,
|
||||
before,
|
||||
after: yield* Effect.promise(() => Bun.file(path.join(updated.directory, "index.js")).text()),
|
||||
version: updated.version,
|
||||
changedDirectory: installed.directory !== updated.directory,
|
||||
unchangedDirectory: unchanged.directory === updated.directory,
|
||||
generations: yield* Effect.promise(() => fs.readdir(root)),
|
||||
updated: yield* npm.check(mutable),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(result.current).toBeFalse()
|
||||
expect(result.outdated).toBeTrue()
|
||||
expect(result.pinnedOutdated).toBeFalse()
|
||||
expect(result.before).toContain('version = "1.0.0"')
|
||||
expect(result.after).toContain('version = "1.1.0"')
|
||||
expect(result.version).toBe("1.1.0")
|
||||
expect(result.changedDirectory).toBeTrue()
|
||||
expect(result.unchangedDirectory).toBeTrue()
|
||||
expect(result.generations).not.toContain("1")
|
||||
expect(result.generations).not.toContain("2")
|
||||
expect(result.updated).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSecret") {}
|
||||
|
||||
const generation = <R>(plugin: EffectPlugin.Plugin<R>, revision = "1") => ({ ...plugin, revision })
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.effect("routes experimental terminal reads through the runtime cell without wrapping results", () =>
|
||||
@@ -81,7 +81,7 @@ describe("Plugin", () => {
|
||||
const location = yield* Location.Service
|
||||
const seen: Location.Info[] = []
|
||||
yield* plugins.activate([
|
||||
generation(
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "location-context",
|
||||
effect: (ctx) =>
|
||||
@@ -215,7 +215,7 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(provider)])
|
||||
yield* plugins.activate([versioned(provider)])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature"])
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces plugins by ID and revision", () =>
|
||||
it.effect("replaces plugins by ID and version", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const agents = yield* Agent.Service
|
||||
@@ -250,24 +250,24 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(managed(), "1")])
|
||||
yield* plugins.activate([versioned(managed(), "1")])
|
||||
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("first")
|
||||
|
||||
description = "second"
|
||||
yield* plugins.activate([generation(managed(), "2")])
|
||||
yield* plugins.activate([versioned(managed(), "2")])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
description = "third"
|
||||
yield* plugins.activate([generation(managed(), "2")])
|
||||
yield* plugins.activate([versioned(managed(), "2")])
|
||||
expect(updates).toBe(2)
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
|
||||
|
||||
yield* plugins.activate(
|
||||
[generation(managed(), "2")],
|
||||
[versioned(managed(), "2")],
|
||||
[
|
||||
{
|
||||
source: { type: "package", target: "broken" },
|
||||
source: { type: "package", package: "broken" },
|
||||
state: { status: "failed", error: "failed to resolve" },
|
||||
features: { server: true },
|
||||
},
|
||||
@@ -288,7 +288,7 @@ describe("Plugin", () => {
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const definitions = ["first", "second"].map((id) =>
|
||||
generation(
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id,
|
||||
effect: (ctx) => ctx.agent.transform((draft) => draft.update(id, () => {})),
|
||||
@@ -315,36 +315,17 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
let loads = 0
|
||||
const plugin = {
|
||||
id: "metadata",
|
||||
revision: "1",
|
||||
source: { type: "package" as const, target: "fixture" },
|
||||
effect: () => Effect.sync(() => loads++),
|
||||
}
|
||||
|
||||
yield* plugins.activate([plugin])
|
||||
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
|
||||
|
||||
expect(loads).toBe(1)
|
||||
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate IDs before replacing active plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const active = Plugin.ID.make("active")
|
||||
const duplicate = "duplicate"
|
||||
yield* plugins.activate([{ id: active, revision: "1", effect: () => Effect.void }])
|
||||
yield* plugins.activate([{ id: active, version: "1", effect: () => Effect.void }])
|
||||
|
||||
const result = yield* plugins
|
||||
.activate([
|
||||
{ id: duplicate, revision: "1", effect: () => Effect.void },
|
||||
{ id: duplicate, revision: "1", effect: () => Effect.void },
|
||||
{ id: duplicate, version: "1", effect: () => Effect.void },
|
||||
{ id: duplicate, version: "1", effect: () => Effect.void },
|
||||
])
|
||||
.pipe(Effect.exit)
|
||||
|
||||
@@ -359,7 +340,7 @@ describe("Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.activate([
|
||||
{ id: "rpc-plugin", revision: "1", features: { rpc: true }, effect: () => Effect.void },
|
||||
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
|
||||
])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
@@ -397,7 +378,7 @@ describe("Plugin", () => {
|
||||
},
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(good), generation(bad)])
|
||||
yield* plugins.activate([versioned(good), versioned(bad)])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("good"),
|
||||
@@ -415,7 +396,7 @@ describe("Plugin", () => {
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([generation(good), generation(bad, "2")])
|
||||
yield* plugins.activate([versioned(good), versioned(bad, "2")])
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
id: Plugin.ID.make("good"),
|
||||
@@ -441,7 +422,7 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "partial-tools",
|
||||
revision: "1",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
@@ -507,8 +488,8 @@ describe("Plugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(previous)])
|
||||
yield* plugins.activate([generation(replacement, "2")])
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
@@ -546,8 +527,8 @@ describe("Plugin", () => {
|
||||
effect: () => Effect.die(new Error("replacement failed")),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(previous)])
|
||||
yield* plugins.activate([generation(replacement, "2")])
|
||||
yield* plugins.activate([versioned(previous)])
|
||||
yield* plugins.activate([versioned(replacement, "2")])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{
|
||||
@@ -568,7 +549,7 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
version: "1",
|
||||
effect: () => Effect.addFinalizer(() => Effect.sync(() => closed.push(id))),
|
||||
})),
|
||||
)
|
||||
@@ -592,7 +573,7 @@ describe("Plugin", () => {
|
||||
),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)]).pipe(Effect.provideService(Secret, "secret"))
|
||||
yield* plugins.activate([versioned(plugin)]).pipe(Effect.provideService(Secret, "secret"))
|
||||
|
||||
expect(visible).toBe(false)
|
||||
}),
|
||||
@@ -605,7 +586,7 @@ describe("Plugin", () => {
|
||||
yield* plugins.activate(
|
||||
["a", "a:b", "雪"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
version: "1",
|
||||
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
|
||||
})),
|
||||
)
|
||||
@@ -667,7 +648,7 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)])
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool")
|
||||
|
||||
yield* plugins.activate([])
|
||||
@@ -699,7 +680,7 @@ describe("Plugin", () => {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)])
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([
|
||||
"context7_look_up",
|
||||
@@ -778,7 +759,7 @@ describe("Plugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)])
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const execution = yield* toolSet.execute({
|
||||
@@ -836,7 +817,7 @@ describe("Plugin", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([generation(plugin)])
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const failure = yield* toolSet
|
||||
|
||||
@@ -38,8 +38,6 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -104,6 +104,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
assert: () => Effect.die("unused permission.assert"),
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
|
||||
@@ -15,15 +15,13 @@ test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
add: (_pkg, options) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href, version: "1.2.3" }
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
resolve: (_pkg, options) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
check: () => Effect.die(new Error("Unexpected check")),
|
||||
update: () => Effect.die(new Error("Unexpected update")),
|
||||
which: () => Effect.die(new Error("Unexpected which")),
|
||||
}),
|
||||
),
|
||||
@@ -32,6 +30,5 @@ test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
|
||||
expect(plugin.id).toBe("config-effect-plugin")
|
||||
expect(plugin.features).toEqual({ tui: true, rpc: true })
|
||||
expect(plugin.source).toEqual({ type: "package", target: "fixture-plugin", version: "1.2.3" })
|
||||
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), {
|
||||
replacements: [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Queue.unbounded<void>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const ready = yield* Deferred.make<Context>()
|
||||
yield* plugins.activate([{ id: "permission-test", version: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
|
||||
const ctx = yield* Deferred.await(ready)
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update("permission-test", (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "deploy", resource: "*", effect: "ask" },
|
||||
{ action: "deploy", resource: "allowed", effect: "allow" },
|
||||
{ action: "deploy", resource: "blocked", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const sessionID = Session.ID.create()
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: location.project.id,
|
||||
slug: "permission-test",
|
||||
directory: location.directory,
|
||||
title: "Permission test",
|
||||
version: "test",
|
||||
agent: "missing",
|
||||
})
|
||||
.run()
|
||||
const input = {
|
||||
id: Permission.ID.create(),
|
||||
sessionID,
|
||||
agent: Agent.ID.make("permission-test"),
|
||||
action: "deploy",
|
||||
resources: ["staging"],
|
||||
save: ["staging"],
|
||||
metadata: { environment: "staging" },
|
||||
source: { type: "tool", messageID: "msg_test", id: "call_test" },
|
||||
} satisfies Permission.AssertInput
|
||||
return { ctx, input, asked }
|
||||
})
|
||||
|
||||
describe("plugin permission.assert", () => {
|
||||
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
|
||||
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
|
||||
yield* Queue.take(asked)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
})
|
||||
if (reply === "cancel") yield* Fiber.interrupt(fiber)
|
||||
if (reply !== "cancel")
|
||||
yield* ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
message: reply === "feedback" ? "Use the test environment" : undefined,
|
||||
})
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
|
||||
if (reply !== "once") {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
if (reply === "reject")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
|
||||
)
|
||||
if (reply === "feedback")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_tag: "Fail",
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-permission-test",
|
||||
setup: async (ctx) => {
|
||||
await expect(
|
||||
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
|
||||
).rejects.toBeDefined()
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
|
||||
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
|
||||
for (const reply of ["once", "reject", "feedback"] as const) {
|
||||
const pending = ctx.permission.assert(input)
|
||||
const settled = pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
await Effect.runPromise(Queue.take(asked))
|
||||
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
save: input.save,
|
||||
})
|
||||
await ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
|
||||
})
|
||||
if (reply === "once") expect(await settled).toEqual({ value: undefined })
|
||||
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
|
||||
if (reply === "feedback")
|
||||
expect(await settled).toEqual({
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
})
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
).effect(ctx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -18,7 +18,6 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { codeModeListings } from "../lib/tool"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||
@@ -168,7 +167,7 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, revision: "1" }])
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -982,7 +981,7 @@ describe("fromPromise", () => {
|
||||
|
||||
const snapshot = yield* registry.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(codeModeListings(snapshot.codeModeCatalog!).map((tool) => tool.path)).toEqual(["acme.hello"])
|
||||
expect(snapshot.codeModeCatalog?.tools.map((tool) => tool.path)).toEqual(["acme.hello"])
|
||||
expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
|
||||
expect(
|
||||
yield* snapshot.execute({
|
||||
|
||||
@@ -24,8 +24,6 @@ function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
check: () => Effect.succeed(false),
|
||||
update: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
@@ -192,68 +189,49 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads native V2 providers and models from the connected OpenCode server", () =>
|
||||
it.live("loads providers and models from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const authorization: Array<string | null> = []
|
||||
const requests: string[] = []
|
||||
return {
|
||||
authorization,
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
requests.push(`${request.method} ${new URL(request.url).pathname}`)
|
||||
const origin = new URL(request.url).origin
|
||||
return Response.json({
|
||||
providers: {
|
||||
remote: {
|
||||
canonical: "openai",
|
||||
name: "Remote",
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
env: ["REMOTE_API_KEY"],
|
||||
settings: {
|
||||
baseURL: `${origin}/v1`,
|
||||
apiKey: "{env:REMOTE_API_KEY}",
|
||||
authToken: "provider-auth",
|
||||
accessToken: "provider-access",
|
||||
custom: "value",
|
||||
},
|
||||
headers: { "x-org-id": "org" },
|
||||
models: {
|
||||
model: {
|
||||
modelID: "api-model",
|
||||
name: "Remote Model",
|
||||
family: "remote",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
settings: {
|
||||
apiKey: "model-secret",
|
||||
authToken: "model-auth",
|
||||
accessToken: "model-access",
|
||||
temperature: 0.5,
|
||||
config: {
|
||||
enterprise: { url: origin },
|
||||
provider: {
|
||||
remote: {
|
||||
name: "Remote",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
api: `${origin}/v1`,
|
||||
env: ["REMOTE_API_KEY"],
|
||||
options: {
|
||||
apiKey: "{env:REMOTE_API_KEY}",
|
||||
headers: { "x-org-id": "org" },
|
||||
custom: "value",
|
||||
},
|
||||
models: {
|
||||
model: {
|
||||
name: "Remote Model",
|
||||
family: "remote",
|
||||
release_date: "2026-01-02",
|
||||
tool_call: true,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
options: { apiKey: "model-secret", temperature: 0.5 },
|
||||
variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
|
||||
cost: { input: 1, output: 2, cache_read: 0.1 },
|
||||
limit: { context: 1000, output: 100 },
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
id: "high",
|
||||
settings: {
|
||||
apiKey: "variant-secret",
|
||||
authToken: "variant-auth",
|
||||
accessToken: "variant-access",
|
||||
temperature: 0.2,
|
||||
},
|
||||
headers: { "x-variant": "high" },
|
||||
},
|
||||
],
|
||||
cost: { input: 1, output: 2, cache: { read: 0.1 } },
|
||||
limit: { context: 1000, output: 100 },
|
||||
override: {
|
||||
name: "Override",
|
||||
provider: { npm: "@ai-sdk/anthropic", api: `${origin}/anthropic` },
|
||||
},
|
||||
disabled: { name: "Disabled", status: "deprecated" },
|
||||
},
|
||||
override: {
|
||||
name: "Override",
|
||||
package: "aisdk:@ai-sdk/anthropic",
|
||||
settings: { baseURL: `${origin}/anthropic` },
|
||||
},
|
||||
disabled: { name: "Disabled", disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -262,19 +240,13 @@ describe("OpencodePlugin", () => {
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ authorization, requests, server }) =>
|
||||
({ authorization, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(Provider.ID.openai, (provider) => {
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai")
|
||||
provider.integrationID = Integration.ID.make("openai")
|
||||
})
|
||||
draft.model.update(Provider.ID.openai, Model.ID.make("api-model"), (model) => {
|
||||
model.package = Provider.aisdk("@ai-sdk/openai")
|
||||
model.settings = { baseURL: "https://upstream.example/v1" }
|
||||
draft.provider.update(Provider.ID.make("remote"), () => {})
|
||||
draft.model.update(Provider.ID.make("remote"), Model.ID.make("model"), (model) => {
|
||||
model.variants = [
|
||||
{
|
||||
id: Model.VariantID.make("custom"),
|
||||
@@ -297,26 +269,20 @@ describe("OpencodePlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
expect(authorization).toEqual(["Bearer secret"])
|
||||
expect(requests).toEqual(["GET /api/v2/config"])
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("remote")))
|
||||
expect(provider).toMatchObject({
|
||||
id: "remote",
|
||||
canonical: "openai",
|
||||
name: "Remote",
|
||||
integrationID: "opencode",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
const integrations = yield* Integration.Service
|
||||
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
|
||||
expect(model).toMatchObject({
|
||||
id: "model",
|
||||
modelID: "api-model",
|
||||
providerID: "remote",
|
||||
canonical: "openai",
|
||||
name: "Remote Model",
|
||||
family: "remote",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
@@ -326,7 +292,6 @@ describe("OpencodePlugin", () => {
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(model.settings).toEqual({ baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 })
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
expect(override.package).toBe(Provider.aisdk("@ai-sdk/anthropic"))
|
||||
expect(override.settings?.baseURL).toBe(`${server.url.origin}/anthropic`)
|
||||
@@ -340,16 +305,13 @@ describe("OpencodePlugin", () => {
|
||||
{
|
||||
id: Model.VariantID.make("high"),
|
||||
settings: { temperature: 0.2 },
|
||||
headers: { "x-variant": "high" },
|
||||
headers: {},
|
||||
},
|
||||
])
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("disabled"))).enabled,
|
||||
).toBe(false)
|
||||
expect(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("stale"))).toBeDefined()
|
||||
expect((yield* catalog.model.get(Provider.ID.openai, Model.ID.make("api-model")))?.settings?.baseURL).toBe(
|
||||
"https://upstream.example/v1",
|
||||
)
|
||||
|
||||
yield* credentials.update(initial.id, { label: "Renamed" })
|
||||
yield* Effect.yieldNow
|
||||
@@ -368,7 +330,6 @@ describe("OpencodePlugin", () => {
|
||||
(count) => count === 2,
|
||||
)
|
||||
expect(authorization).toEqual(["Bearer secret", "Bearer replacement"])
|
||||
expect(requests).toEqual(["GET /api/v2/config", "GET /api/v2/config"])
|
||||
|
||||
yield* credentials.remove(initial.id)
|
||||
yield* Effect.yieldNow
|
||||
@@ -379,146 +340,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes legacy Console OpenAI variant bodies without changing native bodies", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const variants = [
|
||||
{
|
||||
id: "legacy",
|
||||
body: { reasoningEffort: "high", reasoningSummary: "auto" },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "effort-only",
|
||||
body: { reasoningEffort: "low" },
|
||||
expected: { reasoning: { effort: "low" } },
|
||||
},
|
||||
{
|
||||
id: "summary-only",
|
||||
body: { reasoningSummary: "detailed" },
|
||||
expected: { reasoning: { summary: "detailed" } },
|
||||
},
|
||||
{
|
||||
id: "native",
|
||||
body: { reasoning: { effort: "high", summary: "auto" } },
|
||||
expected: { reasoning: { effort: "high", summary: "auto" } },
|
||||
},
|
||||
{
|
||||
id: "mixed",
|
||||
body: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
reasoning: { effort: "high", summary: "detailed" },
|
||||
},
|
||||
expected: { reasoning: { effort: "high", summary: "detailed" } },
|
||||
},
|
||||
{ id: "plain", body: {}, expected: {} },
|
||||
]
|
||||
const models = {
|
||||
astra: {
|
||||
modelID: "api-astra",
|
||||
variants: variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
headers: { "x-variant": variant.id },
|
||||
body: {
|
||||
...variant.body,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
},
|
||||
})),
|
||||
},
|
||||
}
|
||||
const requests: { body: unknown; variant: string | null }[] = []
|
||||
return {
|
||||
variants,
|
||||
requests,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
if (new URL(request.url).pathname === "/responses") {
|
||||
requests.push({ body: await request.json(), variant: request.headers.get("x-variant") })
|
||||
return new Response('data: {"type":"response.completed","response":{"id":"resp_test"}}\n\n', {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
providers: {
|
||||
remote: {
|
||||
canonical: "openai",
|
||||
package: "aisdk:@ai-sdk/openai",
|
||||
settings: { baseURL: new URL(request.url).origin },
|
||||
models: {
|
||||
...models,
|
||||
override: { ...models.astra, package: "aisdk:@ai-sdk/openai-compatible" },
|
||||
},
|
||||
},
|
||||
compatible: {
|
||||
canonical: "openai",
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
models,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ variants, requests, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const credential = Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin },
|
||||
})
|
||||
yield* credentials.create({ integrationID: Integration.ID.make("opencode"), value: credential })
|
||||
yield* addPlugin()
|
||||
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("astra")))
|
||||
expect(model.canonical).toBe(Provider.ID.openai)
|
||||
yield* Effect.forEach(variants, (variant, index) =>
|
||||
Effect.gen(function* () {
|
||||
const projected = required(model.variants.find((item) => item.id === variant.id))
|
||||
expect(projected.body).toEqual({
|
||||
...variant.expected,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
expect(projected.settings).toBeUndefined()
|
||||
const resolved = yield* ModelResolver.resolveModel(model, Model.VariantID.make(variant.id), credential)
|
||||
yield* LLMClient.stream(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer))),
|
||||
)
|
||||
expect(requests).toHaveLength(index + 1)
|
||||
const request = required(requests.at(-1))
|
||||
expect(request.variant).toBe(variant.id)
|
||||
expect(request.body).toMatchObject({
|
||||
...variant.expected,
|
||||
model: "api-astra",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
expect(request.body).not.toHaveProperty("reasoningEffort")
|
||||
expect(request.body).not.toHaveProperty("reasoningSummary")
|
||||
if (variant.id === "plain") expect(request.body).not.toHaveProperty("reasoning")
|
||||
}),
|
||||
)
|
||||
const compatible = required(yield* catalog.model.get(Provider.ID.make("compatible"), Model.ID.make("astra")))
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("override")))
|
||||
for (const model of [compatible, override]) {
|
||||
expect(model.variants.find((variant) => variant.id === "legacy")?.body).toEqual({
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { custom: "unchanged" },
|
||||
})
|
||||
}
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user