mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 16:36:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc1ea87d0 |
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.2
|
||||
bun-version: 1.4.1
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.1
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
|
||||
@@ -1067,7 +1067,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
@@ -3487,7 +3487,7 @@
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
||||
@@ -235,7 +235,6 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
|
||||
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
@@ -264,7 +263,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
ENTERPRISE_SALES_INBOX_EMAIL,
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-6VoxH/BowtAOYdhGqD7ZkFJv+h+/onFmuNZhvBxoGAI=",
|
||||
"aarch64-linux": "sha256-Va77URDCTAkvMQ0g7huFgp1iwb5C6+idSrlLOaFNVB0=",
|
||||
"aarch64-darwin": "sha256-IqCsKuiuuAmK4Z3oOfJ9kX4b4CpCmj6GeTKhXOewc7E=",
|
||||
"x86_64-darwin": "sha256-LXOxHzh6SS63WkfKvwBz+NylfvbcCi+rmXIRmpwu/oE="
|
||||
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
|
||||
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
|
||||
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
|
||||
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"packageManager": "bun@1.4.1",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
@@ -159,7 +159,7 @@
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"bun-types": "1.4.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
|
||||
@@ -86,12 +86,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
// Emulating DPR alone does not exercise Chromium's native hairline border rounding.
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -396,12 +396,7 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -225,81 +225,13 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(sidebar).toHaveCSS("width", "140px")
|
||||
await expect(sidebar).toHaveCSS("width", "130px")
|
||||
|
||||
await tabB.click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(tabB).toBeVisible()
|
||||
})
|
||||
|
||||
for (const count of [0, 26]) {
|
||||
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory, count }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
appearance: { tabLayout: "vertical" },
|
||||
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
type: "draft",
|
||||
server,
|
||||
directory,
|
||||
draftID: `draft_navigation_${index}`,
|
||||
})),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ server, directory: sessionA.directory, count },
|
||||
)
|
||||
await page.goto("/")
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
await expect(sidebar).toHaveCSS("width", "260px")
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
|
||||
for (const width of [260, 180, 140]) {
|
||||
if (width !== 260) {
|
||||
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
|
||||
const bounds = await sidebar.boundingBox()
|
||||
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
|
||||
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(sidebar).toHaveCSS("width", `${width}px`)
|
||||
await testInfo.attach(`navigation-${count}-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const button = sidebar.getByRole("button", { name, exact: true })
|
||||
const label = button.getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
await expect
|
||||
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await button.hover()
|
||||
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
|
||||
await expect
|
||||
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.getByRole("main").hover()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
@@ -427,9 +359,9 @@ for (const profile of [
|
||||
const hint = button.locator('span[aria-hidden="true"]')
|
||||
await expect(hint).toHaveText(row.shortcut)
|
||||
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
|
||||
await expect(hint).toBeHidden()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
await button.hover()
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(hint).toHaveCSS("opacity", "1")
|
||||
await expect
|
||||
.poll(() =>
|
||||
hint.evaluate((element) => {
|
||||
@@ -441,7 +373,7 @@ for (const profile of [
|
||||
)
|
||||
.toBeCloseTo(8, 1)
|
||||
await page.getByRole("main").hover()
|
||||
await expect(hint).toBeHidden()
|
||||
await expect(hint).toHaveCSS("opacity", "0")
|
||||
}
|
||||
|
||||
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
|
||||
@@ -449,10 +381,10 @@ for (const profile of [
|
||||
await home.focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(newSession).toBeFocused()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Shift+Tab")
|
||||
await expect(home).toBeFocused()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
|
||||
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -628,12 +560,7 @@ async function mockServer(page: Page) {
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: sessionA.directory,
|
||||
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -34,14 +34,6 @@ export function createHomeController() {
|
||||
const conn = list[0]
|
||||
if (conn) setSelection({ server: ServerConnection.key(conn) })
|
||||
})
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
layout.home.setSelection(next)
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
|
||||
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
|
||||
|
||||
export type { HomeSessionRecord } from "./records"
|
||||
|
||||
@@ -270,7 +270,14 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const project = homeProjectForSession(session, home.project.list())
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
const project = home.project
|
||||
.list()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directoryKey ||
|
||||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||
)
|
||||
const conn = home.server.focused()
|
||||
if (!conn) return
|
||||
const connKey = ServerConnection.key(conn)
|
||||
|
||||
@@ -36,26 +36,4 @@ describe("buildHomeSessionRecords", () => {
|
||||
|
||||
expect(records.map((record) => record.session.id)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("labels a worktree session with its project before that project's inventory has loaded", () => {
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [{ ...opened, name: "Project A" }],
|
||||
})
|
||||
|
||||
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
|
||||
expect(records[0]?.projectName).toBe("Project A")
|
||||
})
|
||||
|
||||
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
|
||||
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
|
||||
const records = buildHomeSessionRecords({
|
||||
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
|
||||
projectDirectories: () => undefined,
|
||||
projects: () => [opened, nested],
|
||||
})
|
||||
|
||||
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,13 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const project = input
|
||||
.projects()
|
||||
.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
@@ -30,18 +36,3 @@ export function buildHomeSessionRecords(input: {
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
|
||||
// the session's project ID still identifies its added project.
|
||||
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: readonly T[],
|
||||
) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
return (
|
||||
projects.find(
|
||||
(item) =>
|
||||
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||
) ?? projects.find((item) => item.id === session.projectID)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "./registry"
|
||||
import { createRefCountMap } from "@/runtime/server/refcount"
|
||||
import { createRequestQueue } from "@/runtime/server/request-queue"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { useServer } from "./current"
|
||||
|
||||
@@ -115,9 +114,8 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: queue.fetch })
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
|
||||
@@ -6,7 +6,6 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerSync } from "@/runtime/server/sync"
|
||||
import { worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
@@ -21,6 +20,7 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
})
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
|
||||
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
|
||||
throw new Error(`Unexpected request: ${url.pathname}`)
|
||||
},
|
||||
{ preconnect() {} },
|
||||
@@ -47,18 +47,6 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
|
||||
expect(store.project[0]?.worktrees).toEqual([
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
@@ -88,39 +76,70 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads project metadata without enumerating any project's worktrees", async () => {
|
||||
const requests: string[] = []
|
||||
test("loads each project's inventory through its own location using the real client", async () => {
|
||||
const calls: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
calls.push(directory)
|
||||
return Response.json([
|
||||
...Array.from({ length: 300 }, (_, index) => ({
|
||||
id: `historical-${index.toString().padStart(3, "0")}`,
|
||||
canonical: `/history/${index}`,
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
|
||||
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ directory },
|
||||
{ directory: `${directory}/clone` },
|
||||
{ directory: `${directory}/copy`, strategy: "git" },
|
||||
])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
expect(requests).toEqual(["/api/project"])
|
||||
expect(result).toHaveLength(302)
|
||||
expect(result.slice(0, 2)).toMatchObject([
|
||||
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
|
||||
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
["/a/clone", "/a/copy"],
|
||||
["/b/clone", "/b/copy"],
|
||||
])
|
||||
expect(result.map((project) => project.worktrees)).toEqual([
|
||||
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
|
||||
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
|
||||
])
|
||||
expect(calls.toSorted()).toEqual(["/a", "/b"])
|
||||
})
|
||||
|
||||
test("keeps projects whose directory inventory cannot load", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
|
||||
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
|
||||
return Response.json([{ directory: "/a/copy", strategy: "git" }])
|
||||
},
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
|
||||
{ id: "a", sandboxes: ["/a/copy"] },
|
||||
{ id: "b", sandboxes: [] },
|
||||
])
|
||||
expect(result.some((project) => project.id === "test")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@ import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/runtime/server/errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -63,21 +64,42 @@ type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
projects.list().then((items) =>
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
projects.list().then(async (items) => {
|
||||
return (
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((project) => !!project?.id)
|
||||
.map(async (project) => {
|
||||
const directories = await worktrees
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.catch(() => [
|
||||
{ directory: project.canonical },
|
||||
...(project.sandboxes ?? [])
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory))
|
||||
.map((directory) => ({ directory })),
|
||||
])
|
||||
return normalizeProjectInfo({
|
||||
...project,
|
||||
sandboxes: directories
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.canonical, directory)),
|
||||
worktrees: directories,
|
||||
})
|
||||
}),
|
||||
)
|
||||
)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
),
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -85,6 +107,7 @@ export async function bootstrapGlobal(input: {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
}
|
||||
scope: ServerScope
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
@@ -94,17 +117,9 @@ export async function bootstrapGlobal(input: {
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
|
||||
input.setGlobalStore(
|
||||
"project",
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue } from "./request-queue"
|
||||
|
||||
function setup(input?: { limit?: number; stallMs?: number }) {
|
||||
const pending: Array<{ url: string; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
const queue = createRequestQueue({
|
||||
limit: input?.limit ?? 2,
|
||||
stallMs: input?.stallMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
{ preconnect() {} },
|
||||
),
|
||||
})
|
||||
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
|
||||
}
|
||||
|
||||
describe("createRequestQueue", () => {
|
||||
test("caps concurrent requests and starts queued ones as slots free up", async () => {
|
||||
const input = setup()
|
||||
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
|
||||
expect(input.queue.queued()).toBe(1)
|
||||
input.pending[0]!.resolve()
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
|
||||
input.pending.forEach((item) => item.resolve())
|
||||
await Promise.all(responses)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("never counts the event stream against the budget", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
void input.queue.fetch("http://server/api/session")
|
||||
void input.queue.fetch("http://server/api/event")
|
||||
await input.settle()
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
|
||||
expect(input.queue.inflight()).toBe(1)
|
||||
})
|
||||
|
||||
test("aborted requests leave the queue without being sent", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
void input.queue.fetch("http://server/api/first")
|
||||
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
|
||||
controller.abort()
|
||||
await input.settle()
|
||||
input.pending[0]!.resolve()
|
||||
await expect(aborted).rejects.toBeInstanceOf(DOMException)
|
||||
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("a burst that drains promptly is not thrashing", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
|
||||
await input.settle()
|
||||
expect(input.queue.queued()).toBe(10)
|
||||
// Drain two at a time before the stall threshold elapses.
|
||||
for (let round = 0; round < 6; round++) {
|
||||
input.pending.splice(0).forEach((item) => item.resolve())
|
||||
await input.settle()
|
||||
}
|
||||
await Promise.all(responses)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("logs what is in flight and queued once per burst after requests stall", async () => {
|
||||
const input = setup({ stallMs: 5 })
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
|
||||
input.tick(50)
|
||||
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
|
||||
input.tick(100)
|
||||
input.queue.fetch("http://server/api/health").catch(() => undefined)
|
||||
expect(input.logs).toEqual([])
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toEqual([
|
||||
{
|
||||
message: "server thrashing detected",
|
||||
data: {
|
||||
limit: 2,
|
||||
inflight: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
|
||||
],
|
||||
queued: [
|
||||
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
|
||||
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
// Still stalled within the rate limit: no repeat.
|
||||
input.tick(2_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(1)
|
||||
input.tick(10_000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(input.logs).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
type Entry = { method: string; url: string; at: number }
|
||||
|
||||
// Chromium allows six connections per origin. The event stream holds one for the life of the
|
||||
// connection and health probes use their own fetch, so the app's API calls stay below that or
|
||||
// a burst stalls probes and user actions inside the browser where nothing can observe it.
|
||||
export const requestQueueLimit = 4
|
||||
|
||||
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
|
||||
// for a slot indicates the server is not keeping up.
|
||||
export const requestStallMs = 2_000
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
stallMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
const limit = input.limit ?? requestQueueLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
|
||||
const inflight = new Set<Entry>()
|
||||
const waiting: Array<{ entry: Entry; start: () => void }> = []
|
||||
let warned = -Infinity
|
||||
let watcher: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
|
||||
// Debug exports include the console, so list what the server is busy with while requests wait.
|
||||
const watch = () => {
|
||||
watcher = undefined
|
||||
const oldest = waiting[0]?.entry
|
||||
if (!oldest) return
|
||||
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
|
||||
warned = now()
|
||||
log("server thrashing detected", {
|
||||
limit,
|
||||
inflight: [...inflight].map(describe),
|
||||
queued: waiting.map((item) => describe(item.entry)),
|
||||
})
|
||||
}
|
||||
watcher = setTimeout(watch, stallMs)
|
||||
}
|
||||
const release = (entry: Entry) => {
|
||||
inflight.delete(entry)
|
||||
waiting.shift()?.start()
|
||||
}
|
||||
const acquire = (entry: Entry) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = () => {
|
||||
entry.at = now()
|
||||
inflight.add(entry)
|
||||
resolve()
|
||||
}
|
||||
if (inflight.size < limit) return start()
|
||||
waiting.push({ entry, start })
|
||||
watcher ??= setTimeout(watch, stallMs)
|
||||
})
|
||||
|
||||
const fetch: typeof globalThis.fetch = Object.assign(
|
||||
async (resource: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(resource, init)
|
||||
// The event stream is long-lived; never count it against the request budget.
|
||||
if (new URL(request.url).pathname === "/api/event") return base(request)
|
||||
const entry = { method: request.method, url: request.url, at: now() }
|
||||
await acquire(entry)
|
||||
if (request.signal.aborted) {
|
||||
release(entry)
|
||||
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
|
||||
}
|
||||
return base(request).finally(() => release(entry))
|
||||
},
|
||||
// Bun's fetch type carries preconnect; the browser never calls it.
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
|
||||
return {
|
||||
fetch,
|
||||
inflight: () => inflight.size,
|
||||
queued: () => waiting.length,
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@ import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -81,17 +79,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const worktrees = createWorktreeInventory({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
@@ -210,27 +197,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
: project,
|
||||
),
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
)
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((event) => {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.location) {
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
|
||||
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -239,6 +216,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!children.children[key]) return
|
||||
children.mark(key)
|
||||
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
|
||||
if (event.type === "worktree.updated") void bootstrap.refetch()
|
||||
})
|
||||
|
||||
onCleanup(unsub)
|
||||
@@ -283,7 +261,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
worktrees,
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
|
||||
class="-end-2"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={140}
|
||||
min={130}
|
||||
max={520}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
|
||||
@@ -356,10 +356,10 @@ export function Titlebar(props: {
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
>
|
||||
<Icon name="grid-plus" class="shrink-0" />
|
||||
<Icon name="grid-plus" />
|
||||
<span class="min-w-0 truncate">{language.t("home.title")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
|
||||
@@ -654,10 +654,10 @@ export function Titlebar(props: {
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<Icon name="edit" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
scope: ServerScope.local,
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
test("loads once per project, shares in-flight work, and publishes the result", async () => {
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const setupResult = setup(async (directory) => {
|
||||
await gate.promise
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
expect(await second).toHaveLength(2)
|
||||
await setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("refreshes only inventories a view already loaded", async () => {
|
||||
const setupResult = setup(async (directory) => [{ directory }])
|
||||
await setupResult.inventory.refresh("/never-opened")
|
||||
expect(setupResult.calls).toEqual([])
|
||||
await setupResult.inventory.load("/opened")
|
||||
await setupResult.inventory.refresh("/opened")
|
||||
expect(setupResult.calls).toEqual(["/opened", "/opened"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("a failed load is not cached and never rejects the caller", async () => {
|
||||
let fail = true
|
||||
const setupResult = setup(async (directory) => {
|
||||
if (fail) throw new Error("Location unavailable")
|
||||
return [{ directory }]
|
||||
})
|
||||
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
|
||||
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
|
||||
fail = false
|
||||
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
|
||||
expect(setupResult.calls).toEqual(["/repo", "/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
const metadata = {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
test("derives the workspace list from the inventory, excluding the project root", () => {
|
||||
const worktrees = [
|
||||
{ directory: "/repo/" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
{ directory: "/elsewhere" },
|
||||
]
|
||||
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
|
||||
worktree: "/repo",
|
||||
sandboxes: ["/repo/feature", "/elsewhere"],
|
||||
worktrees,
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
|
||||
const project = normalizeProjectInfo(metadata)
|
||||
expect(withWorktreeInventory(project, undefined)).toBe(project)
|
||||
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
|
||||
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
|
||||
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { QueryClient } from "@tanstack/solid-query"
|
||||
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
|
||||
if (!worktrees) return project
|
||||
return {
|
||||
...project,
|
||||
worktrees: [...worktrees],
|
||||
sandboxes: worktrees
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory)),
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
@@ -15,7 +15,6 @@ const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
@@ -41,14 +40,6 @@ const context = createSimpleContext({
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
|
||||
@@ -98,7 +98,7 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
return false
|
||||
}),
|
||||
AWS.sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
to: "contact@anoma.ly",
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
|
||||
SHELL ["/bin/bash", "-lc"]
|
||||
|
||||
ARG NODE_VERSION=24.4.0
|
||||
ARG BUN_VERSION=1.4.2
|
||||
ARG BUN_VERSION=1.4.1
|
||||
|
||||
ENV BUN_INSTALL=/opt/bun
|
||||
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
@@ -206,7 +206,7 @@ function evaluateTemplate(
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", () => input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
|
||||
@@ -245,8 +245,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
|
||||
return yield* open(url).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || (error.code !== 400 && error.code !== 404))
|
||||
return Effect.fail(error)
|
||||
if (!addedCodemode || !(error instanceof StreamableHTTPError) || error.code !== 404) return Effect.fail(error)
|
||||
// Some servers reject unknown query params. Retry once with the user's original URL.
|
||||
return open(new URL(config.url))
|
||||
}),
|
||||
|
||||
@@ -274,12 +274,10 @@ export const OpenAIPlugin = define({
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
|
||||
const major = Number(match?.[1])
|
||||
const minor = Number(match?.[2] ?? 0)
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
|
||||
@@ -2,50 +2,42 @@ export * as SystemPromptPlugin from "./system-prompt.js"
|
||||
|
||||
import { SystemPart } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { SessionSystemPrompt } from "../session/system-prompt.js"
|
||||
|
||||
import PROMPT_GPT from "./system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
import PROMPT_GPT from "./system-prompt/gpt-extension.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
|
||||
export const OpenAIPlugin = make(
|
||||
"openai",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("gpt")) return
|
||||
export const OpenAIPlugin = make("openai", (id) => (id.includes("gpt") ? PROMPT_GPT : undefined), {
|
||||
operation: "append",
|
||||
})
|
||||
|
||||
if (model.id.toLowerCase().includes("gpt-6")) return PROMPT_ASTRA
|
||||
|
||||
return PROMPT_GPT
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const KimiPlugin = make("kimi", (model) => (model.id.toLowerCase().includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined), {
|
||||
operation: "replace",
|
||||
})
|
||||
export const ArceePlugin = make(
|
||||
"arcee",
|
||||
(model) => (model.id.toLowerCase().includes("trinity") ? PROMPT_TRINITY : undefined),
|
||||
{ operation: "replace" },
|
||||
)
|
||||
export const MetaPlugin = make(
|
||||
"meta",
|
||||
(model) => {
|
||||
if (!model.id.toLowerCase().includes("muse")) return
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
(id) => {
|
||||
if (!id.includes("muse")) return
|
||||
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
|
||||
},
|
||||
{ operation: "replace" },
|
||||
)
|
||||
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
getPrompt: (model: Model.Info) => string | undefined,
|
||||
getPrompt: (modelID: string) => string | undefined,
|
||||
options: { operation: "replace" | "append" },
|
||||
) {
|
||||
return define({
|
||||
@@ -59,9 +51,8 @@ function make(
|
||||
const model = (yield* ctx.catalog.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
)
|
||||
const template = getPrompt(model ?? Model.Info.default(event.model.providerID, event.model.id))
|
||||
if (!template) return
|
||||
const prompt = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
const prompt = getPrompt(`${model?.modelID ?? event.model.id} ${model?.family ?? ""}`.toLowerCase())
|
||||
if (!prompt) return
|
||||
if (options.operation === "append") {
|
||||
event.system.splice(1, 0, SystemPart.make(prompt))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- ctrl+p to list available actions
|
||||
- To give feedback, users should report the issue at
|
||||
https://github.com/anomalyco/opencode
|
||||
|
||||
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/v2/docs/
|
||||
|
||||
# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
|
||||
|
||||
# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
|
||||
|
||||
# Tool usage policy
|
||||
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
|
||||
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
|
||||
|
||||
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
|
||||
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the subagent tool]
|
||||
</example>
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
|
||||
</example>
|
||||
@@ -1,45 +0,0 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
- Do not use a skill based solely on keywords, superficial relevance, or its availability. Avoid re-reading skills already available in the conversation unless needed.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
State the main point clearly and early. Keep responses clear and concise, and avoid unnecessary technical jargon. Use only as much structure as needed, and include technical detail only when it helps the conversation. Use clear file paths when referring to files.
|
||||
|
||||
When describing your work, avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not introduce unprompted alternatives through framing such as "X, not Y" or "This isn't about X. It's about Y."
|
||||
|
||||
## Autonomy
|
||||
|
||||
Infer the user's intent and your task scope from their instructions and the prior conversation context. You should bias towards action and carry out the user's intended task until it is completed. If the intent is unclear, progress towards the goal using the available information and ask for clarification while continuing independent work when possible.
|
||||
|
||||
When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution to save time, effort, or tokens. Continue until the user's intended goal is fulfilled, even when it requires sustained work.
|
||||
|
||||
## Intermediate Commentary
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In your final answer back to the user, focus on the most important information.
|
||||
|
||||
# Working in codebases
|
||||
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
|
||||
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
|
||||
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
|
||||
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
+2
-22
@@ -1,30 +1,10 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
|
||||
- Prefer parallelizing independent tool calls.
|
||||
${OPENCODE_TOOL_GUIDANCE}
|
||||
|
||||
# Communication
|
||||
|
||||
Use clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.
|
||||
|
||||
## Intermediate Commentary
|
||||
# Response channels
|
||||
|
||||
As you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.
|
||||
|
||||
By default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.
|
||||
|
||||
Do not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.
|
||||
|
||||
## Final Answer
|
||||
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.
|
||||
|
||||
# Working in codebases
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
In the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.
|
||||
|
||||
# Delegation
|
||||
|
||||
@@ -3,15 +3,10 @@ export * as SessionSystemPrompt from "./system-prompt.js"
|
||||
import PROMPT from "./runner/prompt/system.txt"
|
||||
|
||||
export function make(tools: string[]) {
|
||||
return render(PROMPT, tools)
|
||||
}
|
||||
|
||||
export function render(prompt: string, tools: string[]) {
|
||||
const instructions: string[] = []
|
||||
if (tools.includes("shell")) {
|
||||
instructions.push(
|
||||
"- Prefer dedicated tools over shell commands; fall back to the shell when a tool cannot do what you need.",
|
||||
"- Do not chain shell commands with separators like `echo \"====\";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse.",
|
||||
)
|
||||
}
|
||||
if (tools.includes("write")) {
|
||||
@@ -24,5 +19,5 @@ export function render(prompt: string, tools: string[]) {
|
||||
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
|
||||
)
|
||||
}
|
||||
return prompt.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
|
||||
}
|
||||
|
||||
@@ -71,6 +71,73 @@ const it = testEffect(
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
for (const item of [
|
||||
...["$&", "$$", "$`", "$'"].flatMap((input) => [
|
||||
{ template: "Explain $ARGUMENTS.", input, expected: `Explain ${input}.` },
|
||||
{ template: "Explain $1.", input: `"${input}"`, expected: `Explain ${input}.` },
|
||||
{ template: "Explain.", input, expected: `Explain.\n\n${input}` },
|
||||
]),
|
||||
...["abc", "", "alpha beta", '"alpha beta"', "$1", "$<name>"].map((input) => ({
|
||||
template: "Explain $ARGUMENTS.",
|
||||
input,
|
||||
expected: `Explain ${input}.`,
|
||||
})),
|
||||
{
|
||||
template: "First $1. Rest $2.",
|
||||
input: '"alpha beta" gamma delta',
|
||||
expected: "First alpha beta. Rest gamma delta.",
|
||||
},
|
||||
{ template: "$ARGUMENTS / $ARGUMENTS", input: "$& $$", expected: "$& $$ / $& $$" },
|
||||
]) {
|
||||
it.live(`interpolates ${JSON.stringify(item.template)} with literal input ${JSON.stringify(item.input)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: { text: string; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die(new Error("unused command.list")),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { explain: { template: item.template } } }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
yield* command.execute({
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([{ text: item.expected, delivery: "queue" }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n- Use clear file paths when referring to files.\\n- Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Response channels\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and skip explanation the user did not ask for. Include technical detail only where it helps.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -906,16 +906,14 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
for (const { status, query } of [400, 404].flatMap((status) =>
|
||||
["", "?source=hello%20world&tag=a&tag=b"].map((query) => ({ status, query })),
|
||||
)) {
|
||||
testEffect(Layer.empty).live(`retries MCP initialization ${status} at the original URL: ${query || "no query"}`, () =>
|
||||
for (const query of ["", "?source=hello%20world&tag=a&tag=b"]) {
|
||||
testEffect(Layer.empty).live(`retries an MCP initialization 404 with the original URL: ${query || "no query"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: Array<string | null> = []
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => {
|
||||
headers.push(request.headers.get("x-mcp-test"))
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status }) : undefined
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status: 404 }) : undefined
|
||||
},
|
||||
})
|
||||
const config = new ConfigMCP.Remote({
|
||||
@@ -941,7 +939,7 @@ for (const { status, query } of [400, 404].flatMap((status) =>
|
||||
|
||||
for (const entry of [
|
||||
{ name: "second 404", status: 404, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "400", status: 400, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "500", status: 500, query: "", codemode: undefined, attempts: 1 },
|
||||
@@ -949,10 +947,6 @@ for (const entry of [
|
||||
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "direct tools", status: 404, query: "", codemode: false, attempts: 1 },
|
||||
{ name: "400 with user codemode=true", status: 400, query: "?codemode=true", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with user codemode=false", status: 400, query: "?codemode=false", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with empty user codemode", status: 400, query: "?codemode=", codemode: undefined, attempts: 1 },
|
||||
{ name: "400 with direct tools", status: 400, query: "", codemode: false, attempts: 1 },
|
||||
]) {
|
||||
testEffect(Layer.empty).live(`does not retry MCP beyond the query fallback: ${entry.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -976,25 +970,23 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
for (const status of [400, 404]) {
|
||||
testEffect(Layer.empty).live(`does not strip codemode for an MCP ${status} after initialization`, () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
testEffect(Layer.empty).live("does not strip codemode for an MCP 404 after initialization", () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status: 404 }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
expect(yield* connection.tools().pipe(Effect.flip)).toBeInstanceOf(Error)
|
||||
|
||||
// The SDK tries to recover an expired session on 404, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(status === 404 ? 2 : 1)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
// The SDK tries to recover the expired session, but must keep the same URL.
|
||||
expect(server.state.initializations).toBe(2)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
|
||||
@@ -115,15 +115,6 @@ describe("OpenAIPlugin", () => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
@@ -170,11 +161,6 @@ describe("OpenAIPlugin", () => {
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ describe("SystemPromptPlugin", () => {
|
||||
test("uses granular IDs with a common prefix", () => {
|
||||
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -66,23 +67,19 @@ describe("SystemPromptPlugin", () => {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex", "gpt-6-astra"])
|
||||
for (const id of ["gpt-5", "gpt-4.1", "gpt-5-codex"])
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), () => {})
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("meta/muse-spark-1.1"), (model) => {
|
||||
model.name = "Muse Spark"
|
||||
})
|
||||
})
|
||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), {
|
||||
discard: true,
|
||||
})
|
||||
const cases = [
|
||||
["gpt-5", "# Delegation"],
|
||||
["gpt-4.1", "# Delegation"],
|
||||
["gpt-5", "# Response channels"],
|
||||
["gpt-4.1", "# Response channels"],
|
||||
["o3", fallback],
|
||||
["gpt-5-codex", "# Delegation"],
|
||||
["gpt-6-astra", "Do not settle for a partial"],
|
||||
["gpt-5-codex", "# Response channels"],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", fallback],
|
||||
["claude-sonnet-4", "# Professional objectivity"],
|
||||
["kimi-k2", "# Prompt and Tool Use"],
|
||||
["trinity", "what command should I run to list files"],
|
||||
["meta/muse-spark-1.1", "powered by Muse Spark"],
|
||||
@@ -106,7 +103,7 @@ describe("SystemPromptPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("renders the OpenAI prompt and preserves project instructions", () =>
|
||||
it.effect("appends the OpenAI extension after the baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -116,42 +113,26 @@ describe("SystemPromptPlugin", () => {
|
||||
)
|
||||
yield* SystemPromptPlugin.OpenAIPlugin.effect(pluginHost)
|
||||
const event = context("gpt-5")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
event.tools.shell = { description: "Run a command", input: { type: "object" } }
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text).toStartWith("You are an AI agent powered by OpenCode")
|
||||
expect(event.system[0]?.text).toContain("Prefer dedicated tools over shell commands")
|
||||
expect(event.system[0]?.text).not.toContain("${OPENCODE_TOOL_GUIDANCE}")
|
||||
expect(event.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses catalog names in Meta prompts for Muse model IDs", () =>
|
||||
it.effect("selects the Meta prompt for Muse family model IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
const cases = [
|
||||
["meta/muse-spark-preview", "Muse Spark Preview"],
|
||||
["muse-spark-1.2", "Muse Spark 1.2"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer 30B"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const [id, name] of cases)
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make(id), (model) => {
|
||||
model.name = name
|
||||
})
|
||||
})
|
||||
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
|
||||
|
||||
yield* Effect.forEach(
|
||||
cases,
|
||||
[
|
||||
["meta/muse-spark-preview", "Muse Spark"],
|
||||
["muse-spark-1.2", "Muse Spark"],
|
||||
["meta/muse-glimmer-30b", "Muse Glimmer"],
|
||||
["muse-glimmer-30b", "Muse Glimmer"],
|
||||
] as const,
|
||||
([id, name]) => {
|
||||
const event = context(id)
|
||||
return hooks.trigger("session", "context", event).pipe(
|
||||
@@ -209,19 +190,19 @@ describe("SystemPromptPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* SystemPromptPlugin.KimiPlugin.effect(pluginHost)
|
||||
yield* SystemPromptPlugin.AnthropicPlugin.effect(pluginHost)
|
||||
const gemini = context("gemini-2.5-pro")
|
||||
const kimi = context("kimi-k2")
|
||||
const claude = context("claude-sonnet-4")
|
||||
|
||||
yield* hooks.trigger("session", "context", gemini)
|
||||
yield* hooks.trigger("session", "context", kimi)
|
||||
yield* hooks.trigger("session", "context", claude)
|
||||
|
||||
expect(gemini.system[0]?.text).toBe(fallback)
|
||||
expect(kimi.system[0]?.text).toContain("# Prompt and Tool Use")
|
||||
expect(claude.system[0]?.text).toContain("# Professional objectivity")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects against the catalog ID rather than the physical model ID or family", () =>
|
||||
it.effect("selects against the catalog model ID instead of its alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -247,9 +228,12 @@ describe("SystemPromptPlugin", () => {
|
||||
yield* hooks.trigger("session", "context", physicalCustom)
|
||||
yield* hooks.trigger("session", "context", familyOpenAI)
|
||||
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([expect.stringContaining("# Delegation")])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(physicalOpenAI.system.map((part) => part.text)).toEqual([
|
||||
fallback,
|
||||
expect.stringContaining("# Delegation"),
|
||||
])
|
||||
expect(physicalCustom.system.map((part) => part.text)).toEqual([fallback])
|
||||
expect(familyOpenAI.system.map((part) => part.text)).toEqual([fallback, expect.stringContaining("# Delegation")])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1590,6 +1590,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
@@ -1610,6 +1611,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
defaultSystem,
|
||||
expect.stringContaining("# Delegation"),
|
||||
"Initial context",
|
||||
])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.4.2-alpine AS base
|
||||
FROM oven/bun:1.4.1-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -54,11 +54,6 @@
|
||||
"./component/register-spinner": "./src/component/register-spinner.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/plugin/source.bun.ts",
|
||||
"node": "./src/plugin/source.node.ts",
|
||||
"default": "./src/plugin/source.node.ts"
|
||||
},
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
|
||||
@@ -46,7 +46,6 @@ export function TerminalPane(props: {
|
||||
let restored = false
|
||||
let wantsControl = false
|
||||
let disposed = false
|
||||
let exited = false
|
||||
let size: TerminalSize | undefined
|
||||
let canonicalSize: TerminalSize | undefined
|
||||
let terminalSize: TerminalSize | undefined
|
||||
@@ -204,10 +203,6 @@ export function TerminalPane(props: {
|
||||
if (typeof event.data !== "string") return
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!message || typeof message !== "object" || !("type" in message)) return
|
||||
if (message.type === "exited") {
|
||||
exited = true
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type === "resized" &&
|
||||
"cols" in message &&
|
||||
@@ -271,8 +266,7 @@ export function TerminalPane(props: {
|
||||
if (disposed) return
|
||||
const focused = terminal?.focused
|
||||
terminal = undefined
|
||||
// The removal event arrives separately; keep the terminal visible until then.
|
||||
if (!exited) setFailure("Terminal disconnected")
|
||||
setFailure("Terminal disconnected")
|
||||
if (focused) props.onDisconnect?.()
|
||||
})
|
||||
socket = next
|
||||
|
||||
@@ -197,7 +197,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
|
||||
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
|
||||
const replaced = permanent ? undefined : previewID()
|
||||
if (replaced) family(replaced).forEach((id) => scrollAnchors.delete(id))
|
||||
if (replaced) scrollAnchors.delete(replaced)
|
||||
if (!permanent) setPreview(sessionID)
|
||||
update((draft) => {
|
||||
if (cancelledTabs.has(sessionID)) return
|
||||
@@ -341,7 +341,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
cancelledTabs.add(target)
|
||||
family(target).forEach((id) => scrollAnchors.delete(id))
|
||||
scrollAnchors.delete(target)
|
||||
if (previewID() === target) setPreview(undefined)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
const selected = navigate && current() === target
|
||||
@@ -379,17 +379,17 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
scrollAnchor(sessionID: string) {
|
||||
const target = root(sessionID)
|
||||
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||
return scrollAnchors.get(sessionID)
|
||||
return scrollAnchors.get(target)
|
||||
},
|
||||
setScrollAnchor(sessionID: string, anchor: ScrollAnchor | undefined) {
|
||||
const target = root(sessionID)
|
||||
if (anchor === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||
scrollAnchors.delete(sessionID)
|
||||
scrollAnchors.delete(target)
|
||||
return
|
||||
}
|
||||
const current = scrollAnchors.get(sessionID)
|
||||
const current = scrollAnchors.get(target)
|
||||
if (current?.messageID === anchor.messageID && current.screenY === anchor.screenY) return
|
||||
scrollAnchors.set(sessionID, anchor)
|
||||
scrollAnchors.set(target, anchor)
|
||||
},
|
||||
select(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
@@ -30,8 +31,7 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, localSource } from "./discovery"
|
||||
import { createPluginSources } from "./source"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
@@ -83,6 +83,7 @@ type Registration = {
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
@@ -108,6 +109,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
// One save can emit several watch events. Remember setup failures so those
|
||||
// events do not repeatedly tear down and restore the last good generation.
|
||||
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
|
||||
const sourceVersions = new Map<string, { digest: string; generation: number }>()
|
||||
const sourceGeneration = async (entrypoint: string) => {
|
||||
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
|
||||
const previous = sourceVersions.get(entrypoint)
|
||||
if (previous?.digest === digest) return previous.generation
|
||||
const generation = ++sourceVersion
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
@@ -231,7 +241,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
}
|
||||
const sources = createPluginSources(watcher.wait)
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
@@ -295,7 +304,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sources.read).catch(
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
@@ -524,7 +533,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
.finally(sources.dispose)
|
||||
return disposing
|
||||
}
|
||||
const unregister = lifecycle.add(dispose)
|
||||
@@ -597,7 +605,7 @@ async function resolvePlugin(
|
||||
previous: Registration | undefined,
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
readSource: ReturnType<typeof createPluginSources>["read"],
|
||||
sourceGeneration: (entrypoint: string) => Promise<number>,
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
@@ -608,18 +616,18 @@ async function resolvePlugin(
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
let source = local ? await readSource(entrypoint) : { version: entrypoint, module: await Host.load(entrypoint) }
|
||||
let generation = local ? await sourceGeneration(entrypoint) : undefined
|
||||
while (true) {
|
||||
const version = source.version
|
||||
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod = source.module
|
||||
if (local) {
|
||||
const observed = await readSource(entrypoint)
|
||||
const mod = await Host.load(version)
|
||||
if (generation !== undefined) {
|
||||
const observed = await sourceGeneration(entrypoint)
|
||||
// In-place saves can change the file between hashing and import. Retry
|
||||
// so setup always runs under the generation of the imported bytes.
|
||||
if (version !== observed.version) {
|
||||
source = observed
|
||||
if (generation !== observed) {
|
||||
generation = observed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
@@ -56,3 +56,14 @@ export function localSource(spec: string, directory: string) {
|
||||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Key local plugin imports by a numeric source version so edited sources
|
||||
// re-import fresh instead of hitting the ESM cache. Bun ignores query params
|
||||
// when caching file:// URL imports, so bust with a plain path there; Node keys
|
||||
// its cache on the full URL. Fractional versions break Bun's runtime JSX/solid
|
||||
// plugin hooks, so always truncate them.
|
||||
export function freshSpecifier(entrypoint: string, sourceVersion: number) {
|
||||
const version = Math.trunc(sourceVersion)
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
|
||||
return `${entrypoint}?mtime=${version}`
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { createRequire } from "node:module"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./discovery"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const files = new Set<string>()
|
||||
const visit = (file: string, search = "") => {
|
||||
if (file.split(path.sep).includes("node_modules")) return
|
||||
if (search) delete require.cache[file + search]
|
||||
if (files.has(file)) return
|
||||
files.add(file)
|
||||
// Bun exposes ESM here too. Delete known keys even when absent: rejected
|
||||
// evaluations are not enumerable, but deletion still invalidates them.
|
||||
delete require.cache[file]
|
||||
track(file)
|
||||
if (!/\.[cm]?[jt]sx?$/.test(file)) return
|
||||
// Scan dependencies only; the normal runtime loader still owns compilation,
|
||||
// package resolution, import attributes, and error reporting.
|
||||
const imports = (() => {
|
||||
try {
|
||||
return new Bun.Transpiler({
|
||||
loader: file.endsWith("tsx") ? "tsx" : file.endsWith("jsx") ? "jsx" : /\.[cm]?ts$/.test(file) ? "ts" : "js",
|
||||
target: "bun",
|
||||
}).scan(readFileSync(file, "utf8")).imports
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
for (const item of imports) {
|
||||
const local =
|
||||
item.path.startsWith("./") || item.path.startsWith("../")
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) continue
|
||||
const requested = fileURLToPath(local)
|
||||
// Resolving a workspace symlink can erase its node_modules boundary.
|
||||
if (requested.split(path.sep).includes("node_modules")) continue
|
||||
try {
|
||||
visit(
|
||||
item.kind === "require-call"
|
||||
? createRequire(file).resolve(requested)
|
||||
: Bun.resolveSync(requested, path.dirname(file)),
|
||||
local.search,
|
||||
)
|
||||
} catch {
|
||||
// A missing local dependency may appear on the next save. Leave its
|
||||
// actual failure (or optional fallback) to the native loader.
|
||||
track(path.dirname(requested), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(fileURLToPath(entrypoint))
|
||||
return { version: String(++generation), load: () => Host.load(entrypoint) }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { localSource } from "./discovery"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const version = String(++generation)
|
||||
const fresh = (specifier: string) => {
|
||||
const url = new URL(specifier)
|
||||
url.searchParams.set("__opencode_reload", version)
|
||||
return url.href
|
||||
}
|
||||
const hook = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!context.parentURL || new URL(context.parentURL).searchParams.get("__opencode_reload") !== version)
|
||||
return nextResolve(specifier, context)
|
||||
const local =
|
||||
specifier.startsWith("./") || specifier.startsWith("../")
|
||||
? new URL(specifier, context.parentURL)
|
||||
: localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
|
||||
if (!local) return nextResolve(specifier, context)
|
||||
if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context)
|
||||
const resolved = (() => {
|
||||
try {
|
||||
return nextResolve(specifier, context)
|
||||
} catch (error) {
|
||||
track(path.dirname(fileURLToPath(local)), true)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
if (!resolved.url.startsWith("file:")) return resolved
|
||||
const file = fileURLToPath(resolved.url)
|
||||
if (file.split(path.sep).includes("node_modules")) return resolved
|
||||
track(file)
|
||||
return { ...resolved, url: fresh(resolved.url) }
|
||||
},
|
||||
})
|
||||
const specifier = fresh(entrypoint)
|
||||
return { version: specifier, load: () => Host.load(specifier), dispose: () => hook.deregister() }
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
// Keep source fingerprints and import attempts together. Filesystem events
|
||||
// should reload changed local graphs, not repeat unchanged evaluations.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared: { version: string; load: () => Promise<unknown>; dispose?: () => void } = await prepareSource(
|
||||
entrypoint,
|
||||
track,
|
||||
)
|
||||
if (prepared.dispose) cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = prepared.load().then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// kills a direct file watch) and filtered by basename so bursts in busy
|
||||
// directories stay quiet. Symlinked files are additionally watched at their
|
||||
// resolved target, since edits there emit nothing at the link's location.
|
||||
// Directory targets are watched at their root only; the plugin source loader
|
||||
// adds each resolved local dependency separately, including nested helpers.
|
||||
// Watches are never torn down individually (a stale watch costs one fs handle and a
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "../component/prompt"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
@@ -11,10 +11,11 @@ import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { Slot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes, type RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useUpdateNotification } from "../context/update-notification"
|
||||
import { useExit } from "../context/exit"
|
||||
import { FadeInText } from "../component/fade-in-text"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -32,7 +33,6 @@ export function Home() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [logoWidth, setLogoWidth] = createSignal(0)
|
||||
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
|
||||
const currentLocation = () => route.location ?? data.location.default()
|
||||
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
|
||||
@@ -87,18 +87,15 @@ export function Home() {
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={3} minHeight={0} flexShrink={1} />
|
||||
<box
|
||||
flexShrink={0}
|
||||
onSizeChange={function () {
|
||||
setLogoWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
</box>
|
||||
<box height={1} flexShrink={0} />
|
||||
<UpdateNotification width={logoWidth()} />
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0} position="relative">
|
||||
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||
<box position="absolute" top="100%" left={0} right={0} alignItems="center">
|
||||
<UpdateNotification />
|
||||
</box>
|
||||
</box>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
@@ -121,55 +118,87 @@ export function Home() {
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateNotification(props: { width: number }) {
|
||||
function UpdateNotification() {
|
||||
const update = useUpdateNotification()
|
||||
const exit = useExit()
|
||||
const theme = useTheme()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const backdrop = () => (hovered() ? theme.background.action.primary.hovered : theme.background.default)
|
||||
const remoteMessage = "A remote server cannot be updated from here. Updating it is recommended."
|
||||
const [hovered, setHovered] = createSignal<"primary" | "close">()
|
||||
createEffect(() => {
|
||||
update.notification()
|
||||
setHovered(false)
|
||||
setHovered(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={update.notification()} keyed>
|
||||
{(state) => {
|
||||
const remote = state.source === "server" && state.remote
|
||||
return (
|
||||
<Show when={!remote || state.type === "available"}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
width={props.width}
|
||||
maxWidth="100%"
|
||||
gap={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseUp={() => {
|
||||
if (remote) return update.dismiss()
|
||||
if (state.type === "installed") return exit()
|
||||
update.open?.("notification")
|
||||
}}
|
||||
>
|
||||
<FadeInText fg={theme.text.subdued} backdrop={backdrop()}>
|
||||
<Show when={!remote}>
|
||||
<span style={{ fg: theme.text.action.primary.selected }}>
|
||||
{state.type === "installed" ? "/exit" : "/update"}
|
||||
</span>
|
||||
</Show>
|
||||
{remote
|
||||
? "remote server update available"
|
||||
: state.type === "installed"
|
||||
? ` restart to use v${state.version}`
|
||||
: ` to install v${state.version}`}
|
||||
</FadeInText>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
{(state) => (
|
||||
<box flexShrink={0} marginTop={4} alignItems="center">
|
||||
<Switch>
|
||||
<Match when={state.source === "client" || !state.remote}>
|
||||
<box
|
||||
alignItems="center"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("primary")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => update.open?.("notification")}
|
||||
>
|
||||
<UpdateMessage
|
||||
title={state.type === "installed" ? "Update installed" : "Update available"}
|
||||
description={`Version ${state.version} is ${state.type === "installed" ? "installed" : "available"}. Click for more details`}
|
||||
backdrop={
|
||||
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state.type === "available" && state.source === "server" && state.remote}>
|
||||
<box alignItems="center">
|
||||
<UpdateMessage
|
||||
title="Server update available"
|
||||
description={remoteMessage}
|
||||
backdrop={theme.background.default}
|
||||
/>
|
||||
<FadeInText
|
||||
fg={theme.text.subdued}
|
||||
backdrop={hovered() === "close" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
sweepWidth={stringWidth(remoteMessage)}
|
||||
sweepOffset={Math.floor((stringWidth(remoteMessage) - stringWidth("Close")) / 2)}
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
bg={hovered() === "close" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("close")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={update.dismiss}
|
||||
>
|
||||
Close
|
||||
</FadeInText>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateMessage(props: { title: string; description: string; backdrop: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const titleWidth = stringWidth(props.title)
|
||||
const descriptionWidth = stringWidth(props.description)
|
||||
const width = Math.max(titleWidth, descriptionWidth)
|
||||
return (
|
||||
<FadeInText width={width} height={2} wrapMode="none" fg={theme.text.default} backdrop={props.backdrop}>
|
||||
<span style={{ fg: theme.text.action.primary.selected, attributes: TextAttributes.BOLD }}>
|
||||
{" ".repeat(Math.floor((width - titleWidth) / 2))}
|
||||
{props.title}
|
||||
</span>
|
||||
{"\n"}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{" ".repeat(Math.floor((width - descriptionWidth) / 2))}
|
||||
{props.description}
|
||||
</span>
|
||||
</FadeInText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ import { useLocation } from "../../context/location"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
@@ -94,6 +95,7 @@ import {
|
||||
sessionRowID,
|
||||
turnDuration,
|
||||
turnTokensPerSecond,
|
||||
type BackgroundToolTarget,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
@@ -142,6 +144,7 @@ const context = createContext<{
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
|
||||
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
|
||||
jumpToBackgroundTool: (target: BackgroundToolTarget, beforeMessageID: string) => void
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
@@ -500,8 +503,6 @@ export function Session(props: {
|
||||
})
|
||||
}
|
||||
function saveScrollAnchor() {
|
||||
// Initial layout must not overwrite the saved position before synchronization restores it.
|
||||
if (!restored) return
|
||||
if (!isAwayFromBottom()) {
|
||||
sessionTabs.setScrollAnchor(sessionID, undefined)
|
||||
return
|
||||
@@ -670,6 +671,25 @@ export function Session(props: {
|
||||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
})
|
||||
|
||||
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
|
||||
if (firstJump()) clearMessageNavigation()
|
||||
const jump = () => {
|
||||
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
|
||||
if (index === -1) {
|
||||
if (data.session.message.more(route.sessionID)) prependHistory(0, jump)
|
||||
return
|
||||
}
|
||||
const id = sessionRowID(rows[index]!, boundaries()[index])
|
||||
if (!id) return
|
||||
ensureAllRows(() => {
|
||||
const child = scroll.getRenderable(id)
|
||||
if (!child) return
|
||||
alignMessage(id, Math.max(0, scroll.scrollTop + child.y - scroll.viewport.y - 1))
|
||||
})
|
||||
}
|
||||
jump()
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
ensureAllRowsPending = undefined
|
||||
@@ -1290,6 +1310,7 @@ export function Session(props: {
|
||||
config,
|
||||
mutatePending,
|
||||
pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID),
|
||||
jumpToBackgroundTool,
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
||||
@@ -2036,8 +2057,15 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
|
||||
const source = () => stringValue(metadata()?.source)
|
||||
const target = createMemo<BackgroundToolTarget | undefined>(() => {
|
||||
if (source() !== "shell") return
|
||||
const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID)
|
||||
return id ? { source: "shell", id } : undefined
|
||||
})
|
||||
const completion = () => source() === "subagent" || source() === "shell"
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
@@ -2055,6 +2083,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}`
|
||||
const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading())))
|
||||
const color = () => {
|
||||
if (hover()) return theme.text.action.secondary.hovered
|
||||
if (state() === "error") return theme.text.feedback.error.default
|
||||
if (state() === "cancelled") return theme.text.feedback.warning.default
|
||||
return theme.text.feedback.info.default
|
||||
@@ -2068,7 +2097,19 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
</InlineToolRow>
|
||||
}
|
||||
>
|
||||
<box marginLeft={3}>
|
||||
<box
|
||||
id={target() ? `${target()!.source}-completion:${target()!.id}` : undefined}
|
||||
marginLeft={3}
|
||||
onMouseOver={() => {
|
||||
if (target()) setHover(true)
|
||||
}}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
const item = target()
|
||||
if (!item || renderer.getSelection()?.getSelectedText()) return
|
||||
ctx.jumpToBackgroundTool(item, props.message.id)
|
||||
}}
|
||||
>
|
||||
<text wrapMode="none">
|
||||
<span style={{ fg: color() }}>{heading()}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{suffix()}</span>
|
||||
|
||||
@@ -35,6 +35,8 @@ export type SessionRow =
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export type BackgroundToolTarget = { source: "shell"; id: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
@@ -407,6 +409,29 @@ export function sessionRowID(row: SessionRow, boundaryID?: string) {
|
||||
if (row.type === "part") return `session-part:${row.ref.messageID}:${row.ref.partID}`
|
||||
}
|
||||
|
||||
export function backgroundToolRowIndex(
|
||||
rows: SessionRow[],
|
||||
messages: SessionMessageInfo[],
|
||||
target: BackgroundToolTarget,
|
||||
beforeMessageID: string,
|
||||
) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID)
|
||||
return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => {
|
||||
if (row.type !== "part") return false
|
||||
if (row.ref.partID === target.id) return true
|
||||
const message = byID.get(row.ref.messageID)
|
||||
if (message?.type !== "assistant") return false
|
||||
const part = resolvePart(message, row.ref.partID)
|
||||
return (
|
||||
part?.type === "tool" &&
|
||||
part.name.toLowerCase() === "shell" &&
|
||||
part.state.status !== "streaming" &&
|
||||
part.state.metadata?.shellID === target.id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
backgroundToolRowIndex,
|
||||
cacheReuseDrop,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
@@ -258,6 +259,63 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("finds background tool launch rows for completion navigation", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: completed({ shellID: "sh_first", status: "running" }),
|
||||
time: { created: 1 },
|
||||
},
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-1",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 2 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-1",
|
||||
text: "First background run completed",
|
||||
description: "First run",
|
||||
time: { created: 3 },
|
||||
},
|
||||
assistant("assistant-3", [
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-2",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "running" }),
|
||||
time: { created: 4 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "subagent-foreground",
|
||||
name: "subagent",
|
||||
state: completed({ sessionID: "child-1", status: "completed" }),
|
||||
time: { created: 5 },
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "completion-2",
|
||||
text: "Second background run completed",
|
||||
description: "Second run",
|
||||
time: { created: 6 },
|
||||
},
|
||||
]
|
||||
const rows = reduceSessionRows(messages)
|
||||
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0)
|
||||
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0)
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
@@ -562,3 +620,14 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
|
||||
function pending() {
|
||||
return { status: "streaming" as const, input: "" }
|
||||
}
|
||||
|
||||
function completed(
|
||||
metadata: Record<string, string>,
|
||||
): Extract<SessionMessageAssistantTool["state"], { status: "completed" }> {
|
||||
return {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [{ type: "text", text: "Background" }],
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([40, 120])("completion notices do not navigate at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const session = {
|
||||
id: "ses_notices",
|
||||
title: "Completion notices",
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const notices = [
|
||||
{ source: "shell", state: "completed", shellID: "shell-1", label: "Shell finished", description: "Done" },
|
||||
{
|
||||
source: "shell",
|
||||
state: "error",
|
||||
jobID: "shell-1",
|
||||
label: "Shell failed",
|
||||
description: "Long command ".repeat(30),
|
||||
},
|
||||
{
|
||||
source: "shell",
|
||||
state: "cancelled",
|
||||
shellID: "shell-1",
|
||||
label: "Shell cancelled",
|
||||
description: "Cancelled command",
|
||||
},
|
||||
{ source: "subagent", state: "completed", sessionID: "child-1", label: "Subagent finished", description: "Done" },
|
||||
{ source: "subagent", state: "error", sessionID: "child-1", label: "Subagent failed", description: "Failed" },
|
||||
{
|
||||
source: "subagent",
|
||||
state: "cancelled",
|
||||
sessionID: "child-1",
|
||||
label: "Subagent cancelled",
|
||||
description: "Cancelled",
|
||||
},
|
||||
]
|
||||
const messages = [
|
||||
{ id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } },
|
||||
{
|
||||
id: "assistant-0",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "shell-1",
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "echo done", background: true },
|
||||
content: [{ type: "text", text: "Running" }],
|
||||
metadata: { shellID: "shell-1" },
|
||||
},
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `history-${index}`,
|
||||
type: "user",
|
||||
text: `History message ${index}`,
|
||||
time: { created: index + 3 },
|
||||
})),
|
||||
...notices.map(({ label, description, ...metadata }, index) => ({
|
||||
id: `notice-${index}`,
|
||||
type: "synthetic",
|
||||
text: label,
|
||||
description,
|
||||
metadata,
|
||||
time: { created: index + 30 },
|
||||
})),
|
||||
]
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
|
||||
return undefined
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
args: { sessionID: session.id },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagent cancelled"))
|
||||
await setup.waitForVisualIdle()
|
||||
const find = (root: Renderable): ScrollBoxRenderable | undefined =>
|
||||
root instanceof ScrollBoxRenderable && root.getRenderable("history-19")
|
||||
? root
|
||||
: root.getChildren().map(find).find(Boolean)
|
||||
const scroll = find(setup.renderer.root)
|
||||
if (!scroll) throw new Error("Session scrollbox not found")
|
||||
expect(scroll.scrollTop).toBeGreaterThan(0)
|
||||
const before = scroll.scrollTop
|
||||
for (const notice of notices) {
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const y = lines.findIndex((line) => line.includes(notice.label))
|
||||
expect(y).toBeGreaterThanOrEqual(0)
|
||||
const x = lines[y].indexOf(notice.label)
|
||||
await setup.mockMouse.click(x + 1, y)
|
||||
await setup.waitForVisualIdle()
|
||||
expect(scroll.scrollTop).toBe(before)
|
||||
expect(setup.renderer.currentFocusedRenderable?.id).toBe(scroll.id)
|
||||
expect(setup.captureCharFrame()).toContain(notice.label)
|
||||
}
|
||||
} finally {
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
@@ -587,41 +587,6 @@ test("keeps scroll anchors for open session tabs", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps parent and subagent scroll anchors independent", async () => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
const parent = { messageID: "msg_parent", screenY: -3 }
|
||||
const child = { messageID: "msg_child", screenY: -5 }
|
||||
setup.tabs.setScrollAnchor("root", parent)
|
||||
|
||||
// A short subagent transcript is at the bottom, so it saves no anchor.
|
||||
setup.tabs.setScrollAnchor("child", undefined)
|
||||
expect(setup.tabs.scrollAnchor("root")).toEqual(parent)
|
||||
expect(setup.tabs.scrollAnchor("child")).toBeUndefined()
|
||||
|
||||
setup.tabs.setScrollAnchor("child", child)
|
||||
expect(setup.tabs.scrollAnchor("root")).toEqual(parent)
|
||||
expect(setup.tabs.scrollAnchor("child")).toEqual(child)
|
||||
|
||||
setup.tabs.setScrollAnchor("root", undefined)
|
||||
expect(setup.tabs.scrollAnchor("child")).toEqual(child)
|
||||
|
||||
setup.tabs.close("root")
|
||||
await wait(() => setup.tabs.tabs().length === 0)
|
||||
setup.route.navigate({ type: "session", sessionID: "root" })
|
||||
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "root"))
|
||||
expect(setup.tabs.scrollAnchor("root")).toBeUndefined()
|
||||
expect(setup.tabs.scrollAnchor("child")).toBeUndefined()
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("derives unread state from server session times", async () => {
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
|
||||
@@ -1,731 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { mkdir, rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import "../src/plugin/runtime-plugin-support.bun"
|
||||
import { createPluginSources } from "../src/plugin/source"
|
||||
import { createSourceWatcher } from "../src/plugin/watch"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("a fresh local plugin generation observes edited helper exports", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./helper.ts"')
|
||||
await Bun.write(helper, 'export const value = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export const value = "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
})
|
||||
|
||||
test("tracks transitive imports through the Solid runtime transform", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.tsx", sources.url)
|
||||
const helper = new URL("nested/label.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./panel"')
|
||||
await Bun.write(
|
||||
new URL("panel.tsx", sources.url),
|
||||
'import { label } from "./nested/label"; export const Panel = () => <text>{label}</text>; export const value = label',
|
||||
)
|
||||
await Bun.write(helper, 'export const label = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
await Bun.write(helper, 'export const label = "after"')
|
||||
const after = await sources.read(entry.href)
|
||||
expect(after.version).not.toBe(before.version)
|
||||
expect(after.module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("unchanged bytes are a no-op, reverted bytes get a fresh module", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const first = await sources.read(entry.href)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
expect(await sources.read(entry.href)).toBe(first)
|
||||
await Bun.write(entry, "export default { value: 2 }")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: 2 } })
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const reverted = await sources.read(entry.href)
|
||||
expect(reverted.version).not.toBe(first.version)
|
||||
expect(reverted.module).not.toBe(first.module)
|
||||
expect(reverted.module).toMatchObject({ default: { value: 1 } })
|
||||
})
|
||||
|
||||
test("renamed exports, failed loads, and new dependencies recover without cached helpers", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'import { value } from "./helper"; export default value')
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(helper, "export const renamed = 2")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow()
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(entry, 'import { renamed } from "./helper"; export default renamed')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
await Bun.write(helper, 'export { value as renamed } from "./new/leaf"')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("leaf")
|
||||
await Bun.write(new URL("new/leaf.ts", sources.url), "export const value = 3")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 })
|
||||
})
|
||||
|
||||
test("shared runtime and ordinary package identities survive plugin generations", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
const library = new URL("node_modules/example/index.js", sources.url)
|
||||
await Bun.write(library, 'export default { value: "package" }')
|
||||
const pkg = await Host.load(library.href)
|
||||
if (typeof pkg !== "object" || pkg === null || !("default" in pkg)) throw new Error("Missing package fixture export")
|
||||
for (const label of ["before", "after"]) {
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import value from "example"
|
||||
export { createSignal, Plugin, value }; export const label = ${JSON.stringify(label)}`,
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null) throw new Error("Missing plugin fixture exports")
|
||||
expect("createSignal" in loaded && loaded.createSignal).toBe(createSignal)
|
||||
expect("Plugin" in loaded && loaded.Plugin).toBe(Plugin)
|
||||
expect("value" in loaded && loaded.value).toBe(pkg.default)
|
||||
expect(loaded).toMatchObject({ label })
|
||||
}
|
||||
})
|
||||
|
||||
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("nested/helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { default } from "./nested/helper"')
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
url: import.meta.url, dir: import.meta.dirname, file: import.meta.file,
|
||||
resolved: import.meta.resolve("./asset.txt"),
|
||||
resolvedSync: import.meta.resolveSync("./asset.txt"),
|
||||
asset: await Bun.file(new URL("./asset.txt", import.meta.url)).text(),
|
||||
}`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: {
|
||||
url: helper.href,
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
file: "helper.ts",
|
||||
asset: "asset",
|
||||
resolved: new URL("nested/asset.txt", sources.url).href,
|
||||
resolvedSync: fileURLToPath(new URL("nested/asset.txt", sources.url)),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("literal dynamic imports and JSON join the source graph", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default (await import("./helper")).default')
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import data from "./data.json"; export default data.value')
|
||||
await Bun.write(json, '{"value":1}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
expect(watched).toContain(fileURLToPath(json))
|
||||
await Bun.write(json, '{"value":2}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test("real watchers observe atomic saves to nested, outside-root, and symlinked helpers", async () => {
|
||||
let changes = 0
|
||||
const watcher = createSourceWatcher(() => {
|
||||
changes++
|
||||
})
|
||||
using _watcher = { [Symbol.dispose]: watcher.dispose }
|
||||
await using sources = await fixture(watcher.wait)
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const helper = new URL("shared/nested/helper.ts", sources.url)
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
await Bun.write(entry, 'export { value as default } from "./link"')
|
||||
await symlink(fileURLToPath(helper), fileURLToPath(new URL("plugin/link.ts", sources.url)))
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
const count = changes
|
||||
await Bun.write(new URL(helper.href + ".new"), "export const value = 2")
|
||||
await rename(new URL(helper.href + ".new"), helper)
|
||||
const deadline = Date.now() + 3000
|
||||
while (Date.now() < deadline) {
|
||||
if (changes > count) break
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(changes).toBeGreaterThan(count)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test.each(["", "?mode=plugin", "?mode=plugin#section"])(
|
||||
"Node reloads the local ESM graph without relocating source files: %s",
|
||||
async (suffix) => {
|
||||
await using dir = await tmpdir()
|
||||
const script = path.join(dir.path, "probe.ts")
|
||||
await Bun.write(
|
||||
script,
|
||||
`
|
||||
import { createPluginSources } from ${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))}
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import assert from "node:assert/strict"
|
||||
const entry = new URL("./entry.mjs", import.meta.url)
|
||||
const helper = new URL("./helper.mjs", import.meta.url)
|
||||
const suffix = ${JSON.stringify(suffix)}
|
||||
const watched = []
|
||||
const sources = createPluginSources(async file => { watched.push(file) })
|
||||
try {
|
||||
const library = new URL("./shared/index.mjs", import.meta.url)
|
||||
await mkdir(new URL("./shared", import.meta.url), { recursive: true })
|
||||
await writeFile(library, 'export default { shared: true }')
|
||||
await mkdir(new URL("./node_modules", import.meta.url), { recursive: true })
|
||||
await symlink(fileURLToPath(new URL("./shared", import.meta.url)), fileURLToPath(new URL("./node_modules/example", import.meta.url)), process.platform === "win32" ? "junction" : "dir")
|
||||
const external = await import(library.href)
|
||||
await writeFile(entry, 'import state from "./node_modules/example/index.mjs"; export { state }; export { value as default, source } from "./helper.mjs' + suffix + '"')
|
||||
await writeFile(helper, 'export const value = 1; export const source = import.meta.url')
|
||||
const initial = await sources.read(entry.href)
|
||||
assert.equal(initial.module.default, 1)
|
||||
assert.equal(initial.module.state, external.default)
|
||||
assert.equal(new URL(initial.module.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(initial.module.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(initial.module.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
assert.equal(await sources.read(entry.href), initial)
|
||||
await writeFile(helper, 'export const value = 2; export const source = import.meta.url')
|
||||
const updated = (await sources.read(entry.href)).module
|
||||
assert.equal(updated.default, 2)
|
||||
assert.equal(updated.state, external.default)
|
||||
assert.equal(watched.includes(fileURLToPath(library)), false)
|
||||
assert.equal(new URL(updated.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(updated.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(updated.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
console.log("node graph reload passed")
|
||||
} finally { sources.dispose() }
|
||||
`,
|
||||
)
|
||||
const build = await Bun.build({
|
||||
entrypoints: [script],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
outdir: dir.path,
|
||||
naming: "probe.mjs",
|
||||
})
|
||||
expect(build.success).toBe(true)
|
||||
const child = Bun.spawn(["node", "--no-warnings", path.join(dir.path, "probe.mjs")], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
expect({ stdout, stderr, exit }).toEqual({ stdout: "node graph reload passed\n", stderr: "", exit: 0 })
|
||||
},
|
||||
)
|
||||
|
||||
test("computed imports retain the importing helper's resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import { read } from "./nested/reader"; export default await read("./leaf.mjs")')
|
||||
await Bun.write(
|
||||
new URL("nested/reader.ts", sources.url),
|
||||
"export const read = async (name: string) => (await import(name)).default",
|
||||
)
|
||||
await Bun.write(new URL("nested/leaf.mjs", sources.url), 'export default "computed"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "computed" })
|
||||
})
|
||||
|
||||
test("empty source modules remain valid dependencies", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import "./empty"; export default "ready"')
|
||||
await Bun.write(new URL("empty.ts", sources.url), "")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "ready" })
|
||||
})
|
||||
|
||||
test("folded imports are watched and dead imports need not be installed", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'if (false) require("not-installed"); export default (await import("./" + "helper")).default')
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("cycles retain one canonical entrypoint per generation and old bindings stay pinned", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = {}; export default () => read() === value',
|
||||
)
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import { value } from "./tui"; export const read = () => value')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(before.default()).toBe(true)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = { changed: true }; export default () => read() === value',
|
||||
)
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(after.default()).toBe(true)
|
||||
expect(before.default()).toBe(true)
|
||||
})
|
||||
|
||||
test("old callbacks keep native deferred-import behavior after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper")).default')
|
||||
await Bun.write(helper, 'export default "old helper"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
await Bun.write(helper, 'export default "new helper"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
// Best-effort reload retains the registration, not a snapshot of files that
|
||||
// its callbacks have not imported yet.
|
||||
expect(await before.default()).toBe("new helper")
|
||||
})
|
||||
|
||||
test("each helper resolves packages from its own directory", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { Plugin } from "@opencode-ai/plugin/tui"; import value from "./nested/helper"; export default { Plugin, value }',
|
||||
)
|
||||
await Bun.write(new URL("nested/helper.ts", sources.url), 'import value from "example"; export default value')
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/package.json", sources.url),
|
||||
'{"type":"module","main":"index.js"}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.js", sources.url),
|
||||
`export default ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "nested/" } })
|
||||
})
|
||||
|
||||
test("a warm deferred import can retain its native cache after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper.mjs")).default')
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
expect(await before.default()).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(await before.default()).toBe("cached")
|
||||
})
|
||||
|
||||
test("computed-only dependencies remain outside static reload tracking", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, "export default async name => (await import(name)).default")
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = await sources.read(entry.href)
|
||||
const mod = before.module
|
||||
if (typeof mod !== "object" || mod === null || !("default" in mod) || typeof mod.default !== "function")
|
||||
throw new Error("Missing computed fixture")
|
||||
expect(await mod.default("./helper.mjs")).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
expect(watched).not.toContain(fileURLToPath(helper))
|
||||
expect(await sources.read(entry.href)).toBe(before)
|
||||
})
|
||||
|
||||
test("unchanged evaluation failures do not repeat import-time effects", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const code = `import { appendFileSync } from "node:fs"
|
||||
appendFileSync(new URL("./attempts.log", import.meta.url), "attempt\\n")
|
||||
throw new Error("broken evaluation")`
|
||||
await Bun.write(entry, code)
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\n")
|
||||
await Bun.write(entry, code + "\n// another generation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\nattempt\n")
|
||||
})
|
||||
|
||||
test.each([
|
||||
'export default await import("not-installed-pkg").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = (await import("not-installed-pkg")).default } catch { value = "fallback" }; export default value',
|
||||
'let value; try { value = require("not-installed-pkg") } catch { value = "fallback" }; export default value',
|
||||
'export default await import("./not-installed").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = require("./not-installed") } catch { value = "fallback" }; export default value',
|
||||
])("optional dependencies keep their runtime fallback: %s", async (code) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, code)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "fallback" })
|
||||
})
|
||||
|
||||
test("installed optional dependencies still resolve beside the importing source", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(new URL("node_modules/example/index.cjs", sources.url), 'module.exports = "installed"')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const dynamic = await import("example").then(m => m.default, () => "fallback")
|
||||
let sync; try { sync = require("example") } catch { sync = "fallback" }
|
||||
export default { dynamic, sync }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { dynamic: "installed", sync: "installed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("computed imports reject asynchronously and capture their argument at the call site", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("one.mjs", sources.url), 'export default "one"')
|
||||
await Bun.write(new URL("two.mjs", sources.url), 'export default "two"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":7}')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const load = name => import(name)
|
||||
const fallback = await load("not-installed-pkg").then(m => m.default, () => "fallback")
|
||||
let name = "./one.mjs"
|
||||
const pending = import(name)
|
||||
name = "./two.mjs"
|
||||
const json = name => import(name, { with: { type: "json" } })
|
||||
export default { fallback, value: (await pending).default, json: (await json("./data.json")).default.value }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { fallback: "fallback", value: "one", json: 7 },
|
||||
})
|
||||
})
|
||||
|
||||
test("missing static dependencies still fail the plugin load", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import value from "not-installed-pkg"; export default value')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("not-installed-pkg")
|
||||
})
|
||||
|
||||
test.each([false, true])("plugin errors retain source filenames (during load: %s)", async (duringLoad) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("nested/helper.ts", sources.url),
|
||||
'export function boom() { void import.meta.url; throw new Error("source trace") }',
|
||||
)
|
||||
await Bun.write(entry, `import { boom } from "./nested/helper"; ${duringLoad ? "boom();" : ""} export default boom`)
|
||||
const error = await (async () => {
|
||||
try {
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("default" in loaded) ||
|
||||
typeof loaded.default !== "function"
|
||||
)
|
||||
throw new Error("Missing stack fixture")
|
||||
loaded.default()
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})()
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("source trace")
|
||||
expect(error.stack).toMatch(/nested[/\\]helper\.ts:\d+:\d+/)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["static import", 'import value from "example"; export default value', "import"],
|
||||
["dynamic import", 'export default (await import("example")).default', "import"],
|
||||
["literal require", 'export default require("example")', "require"],
|
||||
["computed require", 'const read = name => require(name); export default read("example")', "require"],
|
||||
["require alias", 'const read = require; export default read("example")', "require"],
|
||||
["require.resolve", 'export default require(require.resolve("example"))', "require"],
|
||||
["import.meta.require", 'export default import.meta.require("example")', "require"],
|
||||
["shadowed require", 'const read = require => require("example"); export default read(name => name)', "example"],
|
||||
["computed local require", 'const read = name => require(name); export default read("./data.json").value', "local"],
|
||||
[
|
||||
"computed optional require",
|
||||
'const read = name => { try { return require(name) } catch { return "fallback" } }; export default read("not-installed-pkg")',
|
||||
"fallback",
|
||||
],
|
||||
])("%s preserves direct runtime resolution", async (_name, code, expected) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/package.json", sources.url),
|
||||
JSON.stringify({
|
||||
exports: { import: "./import.mjs", require: "./require.cjs" },
|
||||
}),
|
||||
)
|
||||
await Bun.write(new URL("node_modules/example/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(new URL("node_modules/example/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":"local"}')
|
||||
await Bun.write(entry, code)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: expected })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: expected })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s helpers preserve source path globals and lexical bindings", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`nested/helper.${extension}`, sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
file: __filename, dir: __dirname,
|
||||
shadowed: ((__filename, __dirname) => [__filename, __dirname])("file", "dir"),
|
||||
asset: await Bun.file(__dirname + "/asset.txt").text(),
|
||||
}`,
|
||||
)
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(entry, `export { default } from "./nested/helper.${extension}"`)
|
||||
const expected = {
|
||||
default: {
|
||||
file: fileURLToPath(helper),
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
shadowed: ["file", "dir"],
|
||||
asset: "asset",
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("createRequire retains its explicit package resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(new URL(directory + "node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.cjs", sources.url),
|
||||
`module.exports = ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createRequire } from "node:module"
|
||||
const require = createRequire(new URL("./nested/helper.ts", import.meta.url))
|
||||
export default require("example")`,
|
||||
)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "nested/" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "nested/" })
|
||||
})
|
||||
|
||||
test("deferred require callbacks use the invalidated native cache", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default () => require("./data.json").value')
|
||||
await Bun.write(helper, '{"value":"before"}')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
await Bun.write(helper, '{"value":"after"}')
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
expect(before.default()).toBe("after")
|
||||
expect(after.default()).toBe("after")
|
||||
})
|
||||
|
||||
test("local package requires select main rather than the ESM module field", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("helper/package.json", sources.url), '{"main":"require.cjs","module":"import.mjs"}')
|
||||
await Bun.write(new URL("helper/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("helper/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(entry, 'export default require("./helper")')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "require" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "require" })
|
||||
})
|
||||
|
||||
test.each([
|
||||
'import value from "example"; export default value',
|
||||
'export default (await import("example")).default',
|
||||
'import value from "./node_modules/example/index.js"; export default value',
|
||||
])("symlinked package instances remain external: %s", async (code) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const library = new URL("lib/index.js", sources.url)
|
||||
await Bun.write(new URL("lib/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(library, 'export default { value: "shared" }')
|
||||
await mkdir(new URL("plugin/node_modules", sources.url), { recursive: true })
|
||||
await symlink(
|
||||
fileURLToPath(new URL("lib", sources.url)),
|
||||
fileURLToPath(new URL("plugin/node_modules/example", sources.url)),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
)
|
||||
const host = await Host.load(library.href)
|
||||
if (typeof host !== "object" || host === null || !("default" in host)) throw new Error("Missing package fixture")
|
||||
for (const generation of [1, 2]) {
|
||||
await Bun.write(entry, `${code}; export const generation = ${generation}`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null || !("default" in loaded))
|
||||
throw new Error("Missing plugin fixture")
|
||||
expect(loaded.default).toBe(host.default)
|
||||
expect(loaded).toMatchObject({ generation })
|
||||
}
|
||||
expect(watched).not.toContain(fileURLToPath(library))
|
||||
})
|
||||
|
||||
test.each(["static", "query", "file-query", "dynamic-query"])(
|
||||
"JSON text %s imports survive graph resolution and edits",
|
||||
async (kind) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
const specifier = kind === "file-query" ? json.href + "?v=1" : "./data.json" + (kind === "static" ? "" : "?v=1")
|
||||
await Bun.write(
|
||||
entry,
|
||||
kind === "dynamic-query"
|
||||
? `const text = (await import(${JSON.stringify(specifier)}, { with: { type: "text" } })).default; export default JSON.parse(text).value`
|
||||
: `import text from ${JSON.stringify(specifier)} with { type: "text" }; export default JSON.parse(text).value`,
|
||||
)
|
||||
await Bun.write(json, '{"value":"before"}')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(json, '{"value":"after"}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["mjs", "json"])("literal %s query imports are watched and reloaded natively", async (extension) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`helper.${extension}`, sources.url)
|
||||
const prefix = extension === "json" ? "" : "export default "
|
||||
await Bun.write(entry, `export default async () => (await import("./helper.${extension}?mode=plugin")).default`)
|
||||
await Bun.write(helper, prefix + '"before"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing query fixture")
|
||||
await Bun.write(helper, prefix + '"after"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
expect(await before.default()).toBe("after")
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}?mode=plugin"`)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
await Bun.write(helper, prefix + '"repaired"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "repaired" })
|
||||
})
|
||||
|
||||
test("query spellings retain distinct module identities and source URLs", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
"export default { url: import.meta.url, path: import.meta.path, file: import.meta.file, filename: __filename }",
|
||||
)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import one from "./helper.mjs?one/path"; import two from "./helper.mjs?two"; export default { same: one === two, ...one }',
|
||||
)
|
||||
const expected = {
|
||||
default: {
|
||||
same: false,
|
||||
url: helper.href + "?one/path",
|
||||
path: fileURLToPath(helper),
|
||||
file: "helper.mjs",
|
||||
filename: fileURLToPath(helper),
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("literal file URLs retain source loading and helper edits", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
await Bun.write(entry, `export { default } from ${JSON.stringify(helper.href)}`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s wildcard package barrels retain live exports", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/index.js", sources.url),
|
||||
'export default { value: "package" }; export let count = 0; export const increment = () => count++',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(`helper.${extension}`, sources.url),
|
||||
'export * from "example"; import value from "example"; export default value',
|
||||
)
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}"`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: { value: "package" } })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "package" } })
|
||||
await Bun.write(entry, `export { default, count, increment } from "./helper.${extension}"`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("increment" in loaded) ||
|
||||
typeof loaded.increment !== "function"
|
||||
)
|
||||
throw new Error("Missing barrel fixture")
|
||||
expect(loaded).toMatchObject({ default: { value: "package" }, count: 0 })
|
||||
loaded.increment()
|
||||
expect(loaded).toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
async function fixture(watch: (file: string) => Promise<void> = async () => {}) {
|
||||
const dir = await tmpdir()
|
||||
const sources = createPluginSources(watch)
|
||||
return {
|
||||
...sources,
|
||||
url: pathToFileURL(dir.path + path.sep),
|
||||
async [Symbol.asyncDispose]() {
|
||||
sources.dispose()
|
||||
await dir[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
user-select: none;
|
||||
|
||||
background-color: var(--v2-background-bg-base);
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
overflow: clip;
|
||||
@@ -80,7 +80,7 @@
|
||||
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-content"] {
|
||||
border: 1px solid var(--v2-border-border-base);
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-top: 0;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user