mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dea9125004 | ||
|
|
43bd2a516b | ||
|
|
dad7688739 | ||
|
|
961b8ccb86 | ||
|
|
97303c39dd | ||
|
|
282c84d79f | ||
|
|
2dcfc89fab | ||
|
|
a222401f19 | ||
|
|
36da0d5c77 | ||
|
|
7819e7f503 | ||
|
|
6e63b970f3 | ||
|
|
610d7e952a | ||
|
|
ac874a6e90 | ||
|
|
f40ecefdef | ||
|
|
c370a1bdd0 | ||
|
|
309f4534fa | ||
|
|
0ae3bf743f | ||
|
|
f94eefaa50 | ||
|
|
a04d72bb39 |
@@ -78,7 +78,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
|
||||
const notices = page.locator('[data-slot="session-timeline-notice"]')
|
||||
await expect(notices).toHaveCount(4)
|
||||
await expect(notices.nth(0)).toContainText("Agent · explore")
|
||||
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
|
||||
await expect(notices.nth(1)).toContainText("explore finished · Search code")
|
||||
await expect(notices.nth(2)).toContainText("Continuing after restart")
|
||||
await expect(notices.nth(3)).toContainText("Skill · Review")
|
||||
@@ -182,20 +182,15 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await expect(card).not.toContainText("(background)")
|
||||
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
await expect(hint).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [cardBox, hintBox, prefixBox] = await Promise.all([
|
||||
card.boundingBox(),
|
||||
hint.boundingBox(),
|
||||
hintPrefix.boundingBox(),
|
||||
])
|
||||
if (!cardBox || !hintBox || !prefixBox) return undefined
|
||||
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
|
||||
if (!cardBox || !hintBox) return undefined
|
||||
return {
|
||||
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
|
||||
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
|
||||
ordered: cardBox.y < hintBox.y,
|
||||
}
|
||||
})
|
||||
@@ -220,10 +215,10 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
await page.locator('[data-component="task-tool-card"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const name of ["shell", "subagent"] as const) {
|
||||
@@ -267,7 +262,7 @@ for (const name of ["shell", "subagent"] as const) {
|
||||
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
|
||||
@@ -286,9 +281,9 @@ test("shows a badge for active background work", async ({ page }) => {
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "1 item running in background" })
|
||||
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
|
||||
await expect(summary).toContainText("1")
|
||||
await expect(summary).toContainText("Running work in background")
|
||||
await expect(summary).toContainText("1 background task running")
|
||||
await summary.click()
|
||||
await expect(
|
||||
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
|
||||
@@ -387,7 +382,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
},
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
@@ -396,7 +391,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
|
||||
await expect(summary).toContainText("2")
|
||||
await summary.click()
|
||||
const list = page.locator('[data-component="session-background-list"]')
|
||||
|
||||
@@ -173,7 +173,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
"href",
|
||||
"#opencode-v2-icon-circle-exclamation",
|
||||
"#opencode-v2-icon-outline-hexagonal-warning",
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -134,7 +134,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeInViewport()
|
||||
if (name !== "read") {
|
||||
const hint = page.locator('[data-component="session-background-hint"]')
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
await expect(hint).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
|
||||
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
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"
|
||||
|
||||
type ProjectApi = ServerApi["project"]
|
||||
type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(new Request(input, init).url)
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({
|
||||
directory: "/repo",
|
||||
project: { id: "project", directory: "/repo", canonical: "/repo" },
|
||||
})
|
||||
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() {} },
|
||||
),
|
||||
})
|
||||
const [store, setStore] = createStore<ServerSync["data"]>({
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
try {
|
||||
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
|
||||
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
|
||||
|
||||
setStore("project", (projects) => projects.map((project) => ({ ...project, name: "Renamed" })))
|
||||
expect(store.project[0]?.name).toBe("Renamed")
|
||||
setStore("project", [])
|
||||
expect(store.project).toEqual([])
|
||||
|
||||
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({})
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
}
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const location = {} as ServerApi["location"]
|
||||
|
||||
@@ -79,25 +79,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
setGlobalStore("project", next)
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const bootstrap = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "bootstrap"],
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverAPI: serverSDK.api,
|
||||
scope: serverSDK.scope,
|
||||
setGlobalStore: setBootStore,
|
||||
setGlobalStore,
|
||||
queryClient,
|
||||
})
|
||||
return Date.now()
|
||||
@@ -105,14 +93,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
enabled: connected(),
|
||||
}))
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const paused = () => untrack(() => globalStore.reload) !== undefined
|
||||
|
||||
const queue = createRefreshQueue({
|
||||
@@ -216,7 +196,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
}
|
||||
|
||||
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
|
||||
setProjects((projects) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
|
||||
)
|
||||
}
|
||||
@@ -275,7 +255,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
set: setGlobalStore,
|
||||
child: children.child,
|
||||
disableMcp: children.disableMcp,
|
||||
// bootstrap,
|
||||
|
||||
@@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,11 +82,14 @@ export default Runtime.handler(Commands, (input) =>
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: service
|
||||
? {
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
}
|
||||
: undefined,
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
{ signal },
|
||||
),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
},
|
||||
|
||||
@@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -29,7 +27,6 @@ export type Options = {
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
replacements: [
|
||||
@@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
|
||||
const next = yield* Effect.scoped(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
@@ -66,7 +62,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (incumbent !== undefined) return
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater
|
||||
.monitor({
|
||||
url,
|
||||
password,
|
||||
managed: options.mode === "service",
|
||||
notify: server.updateAvailable,
|
||||
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
? Effect.raceFirst(
|
||||
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
|
||||
Deferred.await(replacement).pipe(Effect.map(Option.some)),
|
||||
)
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
|
||||
? waitForStdinClose()
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
if (Option.isNone(next)) return
|
||||
yield* spawnReplacement(next.value)
|
||||
})
|
||||
|
||||
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const [command, ...args] = options.command
|
||||
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
|
||||
// We do not monitor the replacement after spawn. A managed TUI
|
||||
// recovers with Service.ensure if startup fails; a future client
|
||||
// restart signal could coordinate that recovery instead.
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
|
||||
},
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to start replacement server", { cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "upgrade"
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
if (policy === "notify") return "notify"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "notify"
|
||||
return "upgrade"
|
||||
return "notify"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,22 +6,17 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("reports patches and minors without automatically installing them", () => {
|
||||
test("reports every available release", () => {
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
|
||||
@@ -32,25 +27,21 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
test("reports majors instead of automatically installing them", () => {
|
||||
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
|
||||
test("reports when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
@@ -71,21 +62,21 @@ describe("updater", () => {
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,154 +1,36 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export type Inspection =
|
||||
| { readonly action: "none" }
|
||||
| { readonly action: Exclude<Action, "none">; readonly version: string }
|
||||
|
||||
type State =
|
||||
| { readonly type: "current" }
|
||||
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
|
||||
| { readonly type: "ready-to-restart"; readonly version: string }
|
||||
|
||||
export interface MonitorInput {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly inspect: () => Effect.Effect<Inspection, Error>
|
||||
readonly install: (version: string) => Effect.Effect<boolean, Error>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
readonly interval?: Duration.Input
|
||||
readonly notificationThreshold?: Duration.Input
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
|
||||
const state = yield* Ref.make<State>({ type: "current" })
|
||||
const applyLock = yield* Semaphore.make(1)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: input.url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
|
||||
})
|
||||
|
||||
const applyIfIdle = () =>
|
||||
applyLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Ref.get(state)
|
||||
if (pending.type !== "available") return
|
||||
const active = yield* Effect.tryPromise({
|
||||
try: () => client.session.active(),
|
||||
catch: (cause) => new Error("Failed to read active sessions", { cause }),
|
||||
})
|
||||
if (Object.keys(active).length > 0) return
|
||||
const latest = yield* input.inspect()
|
||||
if (latest.action !== "upgrade") {
|
||||
yield* Ref.set(state, { type: "current" })
|
||||
return
|
||||
}
|
||||
const installed = yield* input
|
||||
.install(latest.version)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!installed) return
|
||||
const handoff = input.managed
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => client.experimental.persistentPty.handoff(),
|
||||
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
|
||||
})
|
||||
: undefined
|
||||
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
|
||||
if (handoff) yield* input.restart(handoff.handoff)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkServer = Effect.gen(function* () {
|
||||
const result = yield* input.inspect()
|
||||
if (result.action === "notify") {
|
||||
yield* input.notify(result.version)
|
||||
return
|
||||
}
|
||||
if (result.action !== "upgrade") {
|
||||
yield* Ref.update(
|
||||
state,
|
||||
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* Ref.update(state, (current): State => {
|
||||
if (current.type === "ready-to-restart" && current.version === result.version) return current
|
||||
return {
|
||||
type: "available",
|
||||
version: result.version,
|
||||
availableSince: current.type === "available" ? current.availableSince : Date.now(),
|
||||
}
|
||||
})
|
||||
yield* applyIfIdle()
|
||||
const pending = yield* Ref.get(state)
|
||||
if (
|
||||
pending.type === "available" &&
|
||||
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
|
||||
)
|
||||
yield* input.notify(pending.version)
|
||||
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
|
||||
|
||||
const subscribe = Effect.suspend(() =>
|
||||
Stream.fromAsyncIterable(
|
||||
client.event.subscribe(),
|
||||
(cause) => new Error("Update event stream failed", { cause }),
|
||||
).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return applyIfIdle()
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.failed" &&
|
||||
event.type !== "session.execution.interrupted"
|
||||
)
|
||||
return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: () => client.session.wait({ sessionID: event.data.sessionID }),
|
||||
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
|
||||
}).pipe(Effect.andThen(applyIfIdle()))
|
||||
}),
|
||||
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
|
||||
),
|
||||
).pipe(Effect.repeat(Schedule.spaced("1 second")))
|
||||
|
||||
return yield* Effect.all(
|
||||
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -161,13 +43,14 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
@@ -192,7 +75,7 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
@@ -302,19 +185,19 @@ const make = Effect.gen(function* () {
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
|
||||
yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === "disable") {
|
||||
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const version = yield* latest()
|
||||
@@ -325,19 +208,16 @@ const make = Effect.gen(function* () {
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return { action: "none" }
|
||||
return undefined
|
||||
}
|
||||
if (next === "notify") {
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return { action: next, version }
|
||||
}
|
||||
return { action: next, version }
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
const detected = yield* method()
|
||||
if (!detected) {
|
||||
yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
yield* upgrade(detected, version)
|
||||
@@ -349,26 +229,9 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (result.action !== "upgrade") return
|
||||
yield* install(result.version)
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
|
||||
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) {
|
||||
return yield* monitorServer({ ...input, inspect, install })
|
||||
})
|
||||
|
||||
return Service.of({ check, monitor, apply, method, latest, upgrade })
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,9 +12,8 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
|
||||
@@ -1,107 +1,40 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.live("installs and restarts after the final Session settles", () =>
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
|
||||
const installed = yield* Deferred.make<string>()
|
||||
const restarted = yield* Deferred.make<void>()
|
||||
yield* Updater.monitorServer({
|
||||
url: fixture.url,
|
||||
password: "test",
|
||||
managed: true,
|
||||
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
|
||||
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
|
||||
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
|
||||
notify: () => Effect.void,
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
|
||||
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
|
||||
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
|
||||
|
||||
fixture.settle()
|
||||
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
|
||||
expect(
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(installed),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
|
||||
),
|
||||
).toBe("1.1.0")
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(restarted),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
const wait = (promise: Promise<unknown>, message: () => string) =>
|
||||
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
function makeServer() {
|
||||
const encoder = new TextEncoder()
|
||||
const activeRead = Promise.withResolvers<void>()
|
||||
const eventOpened = Promise.withResolvers<void>()
|
||||
const waited = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRead.resolve()
|
||||
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
|
||||
waited.resolve()
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
eventOpened.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.url.origin,
|
||||
activeRead: activeRead.promise,
|
||||
eventOpened: eventOpened.promise,
|
||||
waited: waited.promise,
|
||||
settle() {
|
||||
active = false
|
||||
events?.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_settled",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_test" },
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
events?.close()
|
||||
events = undefined
|
||||
},
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -965,6 +965,8 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,17 +138,6 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -809,16 +811,6 @@ export type SessionCompactionStarted = {
|
||||
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1351,6 +1343,23 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1883,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify" | "auto"
|
||||
update?: "disable" | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -1971,6 +1980,7 @@ export type ConfigEntry =
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
subagent?: boolean
|
||||
subtask?: boolean
|
||||
}
|
||||
}
|
||||
@@ -3063,6 +3073,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3340,6 +3352,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3617,6 +3631,8 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ export function createData(config: CreateDataInput) {
|
||||
.location.get({ location: locationQuery(defaultLocation()) })
|
||||
.then((location) => {
|
||||
const key = locationKey(location)
|
||||
setStore("location", key, { ...store.location[key], info: location })
|
||||
setStore("location", key, { info: location })
|
||||
})
|
||||
.catch((error) => console.error("Failed to preload location", error))
|
||||
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
|
||||
@@ -1038,6 +1038,8 @@ export function createData(config: CreateDataInput) {
|
||||
Object.assign(current, {
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -1048,6 +1050,8 @@ export function createData(config: CreateDataInput) {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
@@ -1105,7 +1109,6 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
setStore("location", key, (data) => ({
|
||||
...data,
|
||||
integration: data?.integration?.map((integration) => {
|
||||
if (integration.id !== event.data.integrationID) return integration
|
||||
const active = integration.connections.find(
|
||||
@@ -1147,7 +1150,6 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "vcs.branch.updated":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
vcs: {
|
||||
branch: {
|
||||
...data?.vcs?.branch,
|
||||
@@ -1165,7 +1167,6 @@ export function createData(config: CreateDataInput) {
|
||||
break
|
||||
case "shell.created":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: {
|
||||
...data?.shell,
|
||||
[event.data.info.id]: { ...event.data.info, location },
|
||||
@@ -1175,7 +1176,6 @@ export function createData(config: CreateDataInput) {
|
||||
case "shell.exited":
|
||||
case "shell.deleted":
|
||||
setStore("location", locationKey(location), (data) => ({
|
||||
...data,
|
||||
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
|
||||
}))
|
||||
break
|
||||
@@ -1801,10 +1801,7 @@ export function createData(config: CreateDataInput) {
|
||||
const input = { location: locationQuery(ref ?? defaultLocation()) }
|
||||
const providers = await api().websearch.providers(input)
|
||||
const key = locationKey(providers.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
websearch: providers.data,
|
||||
})
|
||||
setStore("location", key, { websearch: providers.data })
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
|
||||
@@ -98,13 +98,15 @@ test.each(["started", "cancelled", "failed"])(
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
const model = { providerID: "demo", id: "model" }
|
||||
const providerState = { responseId: "summary-response" }
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -355,8 +355,10 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
setup.data.location.integration.sync(location),
|
||||
setup.data.location.model.sync(location),
|
||||
setup.data.location.provider.sync(location),
|
||||
setup.data.location.reference.sync(location),
|
||||
]),
|
||||
)
|
||||
const references = locations.map((location) => setup.data.location.reference.list(location))
|
||||
requests.length = 0
|
||||
|
||||
const updated: OpenCodeEvent = {
|
||||
@@ -402,6 +404,9 @@ test("refreshes global credential events across every loaded location and worksp
|
||||
["/api/provider", "/other", "workspace-other"],
|
||||
]),
|
||||
)
|
||||
locations.forEach((location, index) =>
|
||||
expect(setup.data.location.reference.list(location)).toBe(references[index]),
|
||||
)
|
||||
requests.length = 0
|
||||
}
|
||||
} finally {
|
||||
@@ -469,6 +474,99 @@ test("refreshes references for the location an update names", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves sibling catalogs through location preload, branch, shell, and websearch updates", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const location = { directory: "/project", project: { id: "project", directory: "/project", canonical: "/project" } }
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const pathname = new URL((input instanceof Request ? input : new Request(input, init)).url).pathname
|
||||
requests.push(pathname)
|
||||
if (pathname === "/api/session/active") return Response.json({})
|
||||
if (pathname === "/api/project") return Response.json([])
|
||||
if (pathname === "/api/location") return Response.json(location)
|
||||
if (pathname === "/api/vcs")
|
||||
return Response.json({ location, data: { branch: { current: "main", default: "main" } } })
|
||||
if (pathname === "/api/reference")
|
||||
return Response.json({
|
||||
location,
|
||||
data: [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
if (pathname === "/api/websearch/provider")
|
||||
return Response.json({ location, data: [{ id: "search", name: "Search" }] })
|
||||
throw new Error(`Unexpected request: ${pathname}`)
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
const shell = {
|
||||
id: "sh_first",
|
||||
status: "running" as const,
|
||||
command: "echo hello",
|
||||
cwd: location.directory,
|
||||
shell: "/bin/sh",
|
||||
file: "/shell-output",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
}
|
||||
|
||||
try {
|
||||
// A live event may arrive before any location reads have populated this key.
|
||||
emit({ type: "shell.created", location, data: { info: shell } })
|
||||
expect(setup.data.shell.get(shell.id)).toMatchObject(shell)
|
||||
const first = setup.data.shell.get(shell.id)
|
||||
await Promise.all([setup.data.location.reference.sync(), setup.data.location.vcs.sync()])
|
||||
const references = setup.data.location.reference.list()
|
||||
expect(references?.map((reference) => [reference.name, reference.path])).toEqual([["docs", "/docs"]])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
|
||||
emit({ type: "vcs.branch.updated", location, data: { branch: "feature" } })
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.created", location, data: { info: { ...shell, id: "sh_second" } } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first", "sh_second"])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "shell.deleted", location, data: { id: "sh_second" } })
|
||||
expect(setup.data.shell.list().map((shell) => shell.id)).toEqual(["sh_first"])
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
|
||||
await setup.data.location.websearch.refresh()
|
||||
expect(setup.data.location.websearch.list()).toEqual([{ id: "search", name: "Search" }])
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
emit({ type: "server.connected", data: {} })
|
||||
await wait(() => setup.data.location.info() !== undefined)
|
||||
expect(setup.data.location.info()).toMatchObject(location)
|
||||
expect(setup.data.location.reference.list()).toBe(references)
|
||||
expect(setup.data.shell.get(shell.id)).toBe(first)
|
||||
expect(setup.data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
|
||||
expect(requests.toSorted()).toEqual([
|
||||
"/api/location",
|
||||
"/api/project",
|
||||
"/api/reference",
|
||||
"/api/session/active",
|
||||
"/api/vcs",
|
||||
"/api/websearch/provider",
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports optimistic sessions as creating until the request settles", async () => {
|
||||
const release = Promise.withResolvers<void>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -73,6 +73,11 @@ export function normalize(input: unknown): Result {
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
@@ -86,7 +91,10 @@ export function normalize(input: unknown): Result {
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const migratedUpdate =
|
||||
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
|
||||
if (update !== undefined) encoded.update = update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
@@ -196,7 +204,6 @@ export function normalize(input: unknown): Result {
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
update: Info.fields.update,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
@@ -10,8 +9,11 @@ import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
@@ -32,6 +34,9 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const subagents = yield* SubagentJob.make
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -67,18 +72,14 @@ export const Plugin = define({
|
||||
yield* ctx.command.transform((editor) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
const subagent = command.subagent ?? command.subtask
|
||||
editor.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const commandAgent = agent === undefined ? undefined : (yield* ctx.agent.get({ agentID: agent })).data
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
@@ -89,15 +90,46 @@ export const Plugin = define({
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
const text = yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
})
|
||||
if (subagent ?? commandAgent?.mode === "subagent") {
|
||||
const parent = yield* sessions.get(input.sessionID)
|
||||
const selected = yield* agents.select(agent ?? parent.agent)
|
||||
const child = yield* sessions.create({
|
||||
parentID: parent.id,
|
||||
title: command.description ?? name,
|
||||
agent: selected.id,
|
||||
model: model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
yield* sessions.prompt({
|
||||
...input.prompt,
|
||||
sessionID: child.id,
|
||||
text: ["You are a subagent spawned by another session.", text].join("\n"),
|
||||
resume: false,
|
||||
})
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID: parent.id,
|
||||
childSessionID: child.id,
|
||||
agent: selected.id,
|
||||
description: command.description ?? name,
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return
|
||||
}
|
||||
if (agent !== undefined) {
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
text,
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
@@ -196,8 +228,8 @@ function evaluateTemplate(
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@ type Active = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
@@ -77,7 +76,7 @@ type BackgroundResult = {
|
||||
backgrounded?: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.scope !== scope) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
@@ -241,7 +240,6 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
@@ -255,18 +253,17 @@ export const make = Effect.gen(function* () {
|
||||
done,
|
||||
backgrounded,
|
||||
scope,
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.flatMap((exit) => settle(id, result.scope, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -16,20 +15,48 @@ export function buildLocationServiceMap(
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const inner = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
|
||||
// Workspace-placed directories exist only inside the workspace, so a
|
||||
// local stat consults the wrong filesystem. Workspace liveness is
|
||||
// owned by placement; do not probe the sandbox here, which would
|
||||
// provision lazily-idle workspaces.
|
||||
idleTimeToLive: (ref) =>
|
||||
ref.workspaceID !== undefined || existsSync(ref.directory) ? Duration.infinity : Duration.zero,
|
||||
})
|
||||
const owner = yield* Effect.scope
|
||||
const booting = MutableHashMap.empty<Location.Ref, object>()
|
||||
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
|
||||
(ref: Location.Ref) => {
|
||||
const build = {}
|
||||
MutableHashMap.set(booting, ref, build)
|
||||
return Layer.fromBuild((memoMap, scope) =>
|
||||
Effect.suspend(() =>
|
||||
Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope),
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const finish = Effect.suspend(() => {
|
||||
// An explicitly invalidated build must not evict its replacement.
|
||||
if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void
|
||||
MutableHashMap.remove(booting, ref)
|
||||
// Evict once per failed build, before its result reaches borrowers.
|
||||
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
|
||||
})
|
||||
// With no borrowers, invalidation closes the entry's scope and
|
||||
// joins this lookup fiber. Let the owner finish that cleanup.
|
||||
return Exit.isFailure(exit)
|
||||
? finish.pipe(Effect.forkIn(owner, { startImmediately: true }), Effect.asVoid)
|
||||
: finish
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Retain healthy graphs. Boot failures, not local filesystem probes,
|
||||
// decide whether a location (including workspace placement) can retry.
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const map = {
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) =>
|
||||
Effect.suspend(() => {
|
||||
const key = LocationServiceMap.canonical(ref)
|
||||
MutableHashMap.remove(booting, key)
|
||||
return inner.invalidate(key)
|
||||
}),
|
||||
}
|
||||
// Cached instances borrow their owner instead of retaining its Layer scope.
|
||||
const bindings: LayerNode.Replacements = [
|
||||
|
||||
+137
-30
@@ -5,7 +5,7 @@ import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import type { PersistentPty } from "./persistent-pty.js"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, Queue, References, Scope, Semaphore } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
@@ -26,43 +26,64 @@ const layer = Layer.effect(
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
const hold = () =>
|
||||
Effect.sync(() => {
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
let closed = false
|
||||
const holdUnsafe = () => {
|
||||
if (closed) return Effect.void
|
||||
const token = {}
|
||||
pending.add(token)
|
||||
ready.closeUnsafe()
|
||||
return Effect.sync(() => {
|
||||
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
|
||||
})
|
||||
}
|
||||
const hold = () => Effect.sync(holdUnsafe)
|
||||
const pendingFailures = yield* Queue.unbounded<PendingFailure>()
|
||||
let discovered: readonly Failure[] = []
|
||||
let inventory: Plugin.Info[] = []
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
return inventory
|
||||
})
|
||||
const host = yield* PluginHost.make({ list })
|
||||
const load = Effect.fnUntraced(function* (plugin: Generation) {
|
||||
const child = yield* Scope.fork(scope)
|
||||
const activation: Activation = { plugin, scope: yield* Scope.fork(scope) }
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() =>
|
||||
const grouped = State.group((failure, refresh) => {
|
||||
activation.failure = {
|
||||
error: `Plugin disabled after ${failure.state}.transform failed. Check server logs for details.`,
|
||||
ref: `err_${crypto.randomUUID().slice(0, 8)}`,
|
||||
}
|
||||
Queue.offerUnsafe(pendingFailures, {
|
||||
plugin,
|
||||
scope: activation.scope,
|
||||
failure,
|
||||
refresh,
|
||||
ref: activation.failure.ref,
|
||||
release: holdUnsafe(),
|
||||
})
|
||||
})
|
||||
const exit = yield* Effect.suspend(() =>
|
||||
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
|
||||
).pipe(
|
||||
grouped,
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.make(Scope.Scope, activation.scope).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.onExit((exit) =>
|
||||
Exit.isFailure(exit) && !activation.failure ? Scope.close(activation.scope, exit) : Effect.void,
|
||||
),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
if (activation.failure || Exit.isSuccess(exit)) return { activation } as const
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
cause: exit.cause,
|
||||
})
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
return { error: Cause.pretty(exit.cause) } as const
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
@@ -81,6 +102,8 @@ const layer = Layer.effect(
|
||||
() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
discovered = failures
|
||||
const current = Array.from(active.values())
|
||||
const changed = definitions.findIndex((definition, index) => {
|
||||
const entry = current[index]
|
||||
@@ -108,29 +131,36 @@ const layer = Layer.effect(
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
if (slot.activation && !slot.activation.failure)
|
||||
yield* Scope.close(slot.activation.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
const slot = previous.get(definition.id)
|
||||
// Reordering healthy registrations does not authorize retrying a failed revision.
|
||||
if (slot?.activation?.failure && slot.plugin.revision === definition.revision) {
|
||||
active.set(definition.id, { ...slot, plugin: definition })
|
||||
continue
|
||||
}
|
||||
const result = yield* load(definition)
|
||||
if (result.activation !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
activation: result.activation,
|
||||
})
|
||||
continue
|
||||
}
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
active.set(definition.id, { plugin: definition, error: result.error })
|
||||
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const fallback = slot?.activation
|
||||
if (!fallback || fallback.failure) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
if (restored.activation !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
activation: restored.activation,
|
||||
error: result.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -149,9 +179,68 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
yield* Queue.take(pendingFailures).pipe(
|
||||
Effect.flatMap((item) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logWarning("disabled plugin after transform failure", {
|
||||
"plugin.id": item.plugin.id,
|
||||
state: item.failure.state,
|
||||
ref: item.ref,
|
||||
cause: Cause.die(item.failure.cause),
|
||||
})
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
// Failure is already recorded on its exact activation, so an old queued item
|
||||
// cannot disable a replacement and teardown need not wait for this worker.
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...discovered]
|
||||
const refreshed = yield* State.batch(item.refresh).pipe(Effect.exit)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
if (Exit.isFailure(refreshed))
|
||||
yield* Effect.logWarning("failed to refresh state after disabling plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause: refreshed.cause,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
// Cleanup must also be scheduled if an inventory observer fails. User finalizers
|
||||
// may await readiness, so never join them under the activation lock or readiness hold.
|
||||
Effect.ensuring(
|
||||
Scope.close(item.scope, Exit.void).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to clean up disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
),
|
||||
),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) =>
|
||||
Effect.logError("failed to report disabled plugin", {
|
||||
"plugin.id": item.plugin.id,
|
||||
ref: item.ref,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(item.release),
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const close = (exit: Exit.Exit<unknown, unknown>) =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
closed = true
|
||||
pending.clear()
|
||||
ready.openUnsafe()
|
||||
active.clear()
|
||||
yield* State.shutdown(Scope.close(scope, exit))
|
||||
}),
|
||||
@@ -168,19 +257,37 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
// `plugin` is the requested definition; `activation` is its last activation, which may have
|
||||
// failed or be an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly activation?: Activation
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Share the activation across slot snapshots so teardown sees failures synchronously,
|
||||
// including failures discovered after activate() has captured its previous slots.
|
||||
type Activation = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
failure?: { readonly error: string; readonly ref: string }
|
||||
}
|
||||
|
||||
type PendingFailure = {
|
||||
readonly plugin: Generation
|
||||
readonly scope: Scope.Closeable
|
||||
readonly failure: State.Failure
|
||||
readonly refresh: Effect.Effect<void>
|
||||
readonly ref: string
|
||||
readonly release: Effect.Effect<void>
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
const failure = slot.activation?.failure ?? (slot.error === undefined ? undefined : { error: slot.error })
|
||||
return {
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
state: failure === undefined ? { status: "active" } : { status: "failed", ...failure },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,15 @@ const layer = Layer.effect(
|
||||
// The heterogeneous registry erases handlers after their selected schema validates input.
|
||||
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
|
||||
return execution
|
||||
}).pipe(Effect.catch((error) => encodeError(method, error)))
|
||||
}).pipe(
|
||||
Effect.catch((error) => encodeError(method, error)),
|
||||
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* encode(method.output, result).pipe(
|
||||
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Database } from "./database/database.js"
|
||||
import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, RelativePath } from "./schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./session/error.js"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
@@ -62,7 +61,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { InstructionEntry } from "./session/instruction-entry.js"
|
||||
|
||||
@@ -168,15 +166,7 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
readonly move: SessionMove.Interface["move"]
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
) => ReturnType<Session.Handle["prompt"]>
|
||||
@@ -232,18 +222,15 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const projects = yield* Project.Service
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const instances = yield* Instance.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const sessions = yield* Session.make()
|
||||
const admission = yield* SessionInbox.Service
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
const result = Service.of({
|
||||
@@ -410,45 +397,7 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
move: Effect.fn("Session.move")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const payload = yield* SessionMove.prepare({ ...input, session }).pipe(
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(Project.Service, projects),
|
||||
Effect.provideService(LocationServiceMap.Service, locations),
|
||||
)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* result.get(input.sessionID)
|
||||
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
// Active runners must hand off at a step boundary to retain their continuation.
|
||||
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
wait: (sessionID) => sessions.forSession(sessionID).wait(),
|
||||
active: execution.active,
|
||||
@@ -499,10 +448,9 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionStore.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
LocationServiceMap.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
App.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -367,6 +367,7 @@ export const layer = Layer.effect(
|
||||
const chunks: string[] = []
|
||||
let failure: SessionError.Error | undefined
|
||||
let usage: SessionUsage.Recorded | undefined
|
||||
let providerState: SessionMessage.ProviderState | undefined
|
||||
const recordUsage = Effect.suspend(() =>
|
||||
usage
|
||||
? bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -407,6 +408,7 @@ export const layer = Layer.effect(
|
||||
// Ignored tool calls never enter the follow-up history or need fabricated results.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
chunks.length = 0
|
||||
providerState = undefined
|
||||
yield* llm
|
||||
.stream(
|
||||
attempt === 0
|
||||
@@ -436,6 +438,10 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
providerState =
|
||||
event.providerMetadata?.[
|
||||
context.model.model.route.providerMetadataKey ?? context.model.model.provider
|
||||
]
|
||||
const step = SessionUsage.record(event.usage, context.model.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
@@ -482,6 +488,8 @@ export const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: context.session.id,
|
||||
reason: input.reason,
|
||||
model: context.model.ref,
|
||||
providerState,
|
||||
text: summary,
|
||||
recent: history.recent,
|
||||
})
|
||||
|
||||
@@ -176,13 +176,7 @@ export const layer = (options?: Options) =>
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
|
||||
return (
|
||||
assistant.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || "Subagent completed without a text response."
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -410,6 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
@@ -422,6 +424,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
|
||||
@@ -28,11 +28,9 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
delivery: "send-attempted" | "provider-observed" | "terminal"
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
@@ -130,14 +128,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -198,7 +191,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
active.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
@@ -226,8 +219,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
channel.active?.delivery === "provider-observed" ||
|
||||
channel.active?.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: error.reason._tag === "Transport" && error.reason.code === "1009"
|
||||
@@ -256,7 +249,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("Session WebSocket owner is closed", {
|
||||
@@ -288,7 +280,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
@@ -314,7 +305,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
@@ -326,9 +316,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
delivery: "send-attempted",
|
||||
}
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
@@ -366,7 +358,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -375,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
lifecycle.delivery = "terminal"
|
||||
active.delivery = "terminal"
|
||||
const staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
@@ -411,7 +403,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
@@ -448,7 +440,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let execution: WebSocketChannelExecution | undefined
|
||||
return Effect.succeed({
|
||||
get http() {
|
||||
@@ -456,7 +447,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
},
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.andThen(start(owner, exchange)),
|
||||
Effect.tap((started) =>
|
||||
Effect.sync(() => {
|
||||
execution = started
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
export * as SessionMove from "./move.js"
|
||||
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Project } from "../project.js"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { NotFoundError } from "./error.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionExecution } from "./execution.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionProjector } from "./projector.js"
|
||||
import { SessionRunner } from "./runner/index.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedError<DestinationNotFoundError>()(
|
||||
"Session.DestinationNotFoundError",
|
||||
@@ -26,36 +37,138 @@ export class DestinationUnavailableError extends Schema.TaggedError<DestinationU
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export const prepare = Effect.fn("SessionMove.prepare")(function* (input: {
|
||||
session: Session.Info
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
}) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const value = input.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(input.session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const payload: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(payload.location)),
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
export interface Interface {
|
||||
readonly move: (input: {
|
||||
sessionID: Session.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
delivery?: SessionInbox.Delivery
|
||||
}) => Effect.Effect<
|
||||
void,
|
||||
NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError | DestinationUnavailableError
|
||||
>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionMove") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const projects = yield* Project.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const instances = yield* Instance.Service
|
||||
const admission = yield* SessionInbox.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const get = Effect.fn("SessionMove.get")(function* (sessionID: Session.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* new NotFoundError({ sessionID })
|
||||
return session
|
||||
})
|
||||
|
||||
const resolveDestination = Effect.fn("SessionMove.resolveDestination")(function* (
|
||||
session: Session.Info,
|
||||
input: Parameters<Interface["move"]>[0],
|
||||
) {
|
||||
const value = input.directory.trim()
|
||||
const expanded =
|
||||
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(session.location.directory, expanded))
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
const project = yield* projects.resolve(directory)
|
||||
const destination: SessionInbox.MovePayload = {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
}
|
||||
yield* locations.contextEffect(destination.location).pipe(
|
||||
Effect.scoped,
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return payload
|
||||
return destination
|
||||
})
|
||||
|
||||
const sourceUnavailable = Effect.fn("SessionMove.sourceUnavailable")(function* (session: Session.Info) {
|
||||
if (yield* execution.isActive(session.id)) return false
|
||||
if (!(yield* fs.isDir(session.location.directory))) return true
|
||||
return yield* SessionRunner.Service.pipe(
|
||||
instances.provide(session),
|
||||
Effect.as(false),
|
||||
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
move: Effect.fn("SessionMove.move")(function* (input) {
|
||||
const session = yield* get(input.sessionID)
|
||||
const destination = yield* resolveDestination(session, input)
|
||||
// Probe outside the inbox lock so cancellation remains available during initialization.
|
||||
const unavailable = yield* sourceUnavailable(session)
|
||||
const item = SessionInbox.Item.make({
|
||||
type: "move",
|
||||
payload: destination,
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
yield* SessionInbox.serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const latest = yield* get(input.sessionID)
|
||||
// Only recover the placement we probed; active runners retain their step-boundary handoff.
|
||||
if (
|
||||
unavailable &&
|
||||
latest.location.directory === session.location.directory &&
|
||||
latest.location.workspaceID === session.location.workspaceID &&
|
||||
!(yield* execution.isActive(input.sessionID))
|
||||
) {
|
||||
const cancellations = (yield* SessionInbox.moveIDs(database.db, input.sessionID)).map(
|
||||
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
|
||||
)
|
||||
const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...destination }] as const
|
||||
const first = cancellations[0]
|
||||
if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid)
|
||||
return yield* bus.publishAll([first, ...cancellations.slice(1), moved])
|
||||
}
|
||||
yield* admission
|
||||
.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
item,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
LocationServiceMap.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
Instance.node,
|
||||
SessionInbox.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -90,10 +90,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
const assistantMessageID = input.assistantMessageID
|
||||
let stepStarted = false
|
||||
let stepFailed = false
|
||||
let providerFailed = false
|
||||
let outputStarted = false
|
||||
let stepStreamed = false
|
||||
let stepFailure: SessionError.Error | undefined
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
@@ -112,8 +110,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const streamed = Effect.fnUntraced(function* () {
|
||||
if (stepStreamed) return
|
||||
stepStreamed = true
|
||||
yield* bus.publish(SessionEvent.Step.Streamed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -367,9 +363,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
}) {
|
||||
if (stepFailed || stepFailure === undefined) return
|
||||
if (stepFailure === undefined) return
|
||||
const assistantMessageID = yield* startAssistant()
|
||||
stepFailed = true
|
||||
yield* bus.publish(SessionEvent.Step.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
|
||||
@@ -3,6 +3,19 @@ export * as SubagentCompletion from "./subagent-completion.js"
|
||||
import { Effect } from "effect"
|
||||
import type { Job } from "../job.js"
|
||||
import type { Session } from "../session.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
|
||||
export const NO_TEXT = "Subagent completed without a text response."
|
||||
|
||||
export function text(message: SessionMessage.Info | undefined) {
|
||||
if (message?.type !== "assistant") return NO_TEXT
|
||||
return (
|
||||
message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || NO_TEXT
|
||||
)
|
||||
}
|
||||
|
||||
export const deliver = Effect.fnUntraced(function* (
|
||||
sessions: Pick<Session.Interface, "synthetic">,
|
||||
@@ -16,7 +29,7 @@ export const deliver = Effect.fnUntraced(function* (
|
||||
const recovery = input.recovery
|
||||
const text =
|
||||
input.status === "completed"
|
||||
? (input.output ?? "Subagent completed without a text response.")
|
||||
? (input.output ?? NO_TEXT)
|
||||
: input.status === "error"
|
||||
? (input.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as SubagentJob from "./subagent-job.js"
|
||||
|
||||
import { Effect, Scope } from "effect"
|
||||
import { Job } from "../job.js"
|
||||
import { Session } from "../session.js"
|
||||
import { SubagentCompletion } from "./subagent-completion.js"
|
||||
|
||||
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
|
||||
|
||||
interface Runner {
|
||||
start: (recovery: Recovery) => Effect.Effect<Job.Info>
|
||||
background: (recovery: Recovery) => Effect.Effect<void>
|
||||
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const make: Effect.Effect<Runner, never, Session.Service | Job.Service | Scope.Scope> = Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One observer per job generation, including continuations of the same child.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
const notify = Effect.fn("SubagentJob.notify")(function* (recovery: Recovery, startedAt: number) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
start: (recovery: Recovery) =>
|
||||
jobs.start({
|
||||
id: recovery.childSessionID,
|
||||
type: "subagent",
|
||||
title: recovery.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: Effect.gen(function* () {
|
||||
yield* sessions.resume(recovery.childSessionID)
|
||||
const messages = yield* sessions.messages({ sessionID: recovery.childSessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
return SubagentCompletion.text(assistant)
|
||||
}),
|
||||
}),
|
||||
background: Effect.fn("SubagentJob.background")(function* (recovery: Recovery) {
|
||||
const info = yield* jobs.background(recovery.childSessionID)
|
||||
if (info) yield* notify(recovery, info.started_at)
|
||||
}),
|
||||
notify,
|
||||
}
|
||||
})
|
||||
@@ -297,6 +297,9 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
|
||||
metadata: meta,
|
||||
summary: redact("compaction-summary", message.id, message.summary),
|
||||
recent: redact("compaction-recent", message.id, message.recent),
|
||||
...(message.status === "completed"
|
||||
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return { ...message, metadata: meta }
|
||||
|
||||
@@ -149,6 +149,7 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
const char = input[index]
|
||||
if (!wordStarted) wordStart = index
|
||||
if (!quote && !wordStarted) {
|
||||
if (char === " " || char === "\t") continue
|
||||
const structure = structures.at(-1)
|
||||
const token = /^[A-Za-z_][A-Za-z0-9_]*(?=[ \t\n;()<>]|$)/.exec(input.slice(index))?.[0]
|
||||
if (structure?.kind === "case" && structure.phase === "header" && token === "in") {
|
||||
@@ -166,13 +167,24 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (structure?.kind === "for" && structure.phase === "header" && char === "(" && input[index + 1] !== "(") {
|
||||
const values = bashExpansion(input, index, depth, "array")
|
||||
if (!values) return { kind: "opaque", reason: "compound-command" }
|
||||
finishCommand()
|
||||
const failure = addSubstitutions(values)
|
||||
if (failure) return failure
|
||||
commands.push(...nestedCommands.splice(0))
|
||||
// Zsh permits a sublist or brace group directly after the value list, without do/done.
|
||||
structure.phase = "do"
|
||||
if (!/^(?:[ \t\n;]|\\\n|#[^\n]*(?:\n|$))*do(?=[ \t\n;]|$)/.test(input.slice(values.end + 1))) structures.pop()
|
||||
index = values.end
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
if (!words.length && !hasRedirect && !compoundEnd) {
|
||||
const definition =
|
||||
/^(?:function[ \t]+[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*\([ \t]*\))?|[A-Za-z_][A-Za-z0-9_]*[ \t]*\([ \t]*\))[ \t\n]*(?=[{(])/.exec(
|
||||
input.slice(index),
|
||||
)
|
||||
const definition = bashFunctionHead(input, index)
|
||||
if (definition && !header()) {
|
||||
index += definition[0].length - 1
|
||||
index += definition.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
@@ -536,6 +548,14 @@ function scanBash(input: string, depth: number, budget: { remaining: number }):
|
||||
|
||||
type BashExpansion = { source: string; end: number; substitutions?: string[] }
|
||||
|
||||
function bashFunctionHead(input: string, start: number) {
|
||||
// Share recognition with delimiter scanning so case patterns in function bodies do not close the outer group.
|
||||
// Names need not be variable identifiers. Zsh permits anonymous functions, including in an if condition.
|
||||
return /^(?!if(?:[ \t]|\\\n)*\()(?:function[ \t]+(?:\\\n[ \t]*)*[A-Za-z_][A-Za-z0-9_.:-]*(?:(?:[ \t]|\\\n)*\([ \t]*\))?|(?:[A-Za-z_][A-Za-z0-9_.:-]*(?:[ \t]|\\\n)*)?\([ \t]*\))(?:[ \t\n]|\\\n|#[^\n]*(?:\n|$))*(?=[{(]|\[\[(?=[ \t\n])|(?:if|while|until|for|select|case)[ \t\n])/.exec(
|
||||
input.slice(start),
|
||||
)?.[0]
|
||||
}
|
||||
|
||||
function bashDelimited(input: string, start: number, depth: number): BashExpansion | undefined {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return
|
||||
const close = input[start] === "{" ? "}" : ")"
|
||||
@@ -544,6 +564,7 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
let commandStart = true
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (char === " " || char === "\t") continue
|
||||
if (char === "\\") {
|
||||
if (input[index + 1] !== "\n") commandStart = false
|
||||
index++
|
||||
@@ -566,6 +587,11 @@ function bashDelimited(input: string, start: number, depth: number): BashExpansi
|
||||
continue
|
||||
}
|
||||
const boundary = index === start + 1 || /[ \t\n;|&(){}]/.test(input[index - 1])
|
||||
const definition = commandStart && boundary ? bashFunctionHead(input, index) : undefined
|
||||
if (definition) {
|
||||
index += definition.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "#" && boundary) {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
@@ -725,7 +751,10 @@ function bashExpansion(
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (kind === "array" && "<>=".includes(char) && input[index + 1] === "(") {
|
||||
if (
|
||||
((kind === "array" && "<>=".includes(char)) || (kind === "test" && "<>".includes(char))) &&
|
||||
input[index + 1] === "("
|
||||
) {
|
||||
const nested = bashDelimited(input, index + 1, depth + 1)
|
||||
if (!nested) return
|
||||
substitutions.push(nested.source)
|
||||
|
||||
+86
-19
@@ -31,6 +31,49 @@ export interface Transformable<Editor> {
|
||||
readonly reload: Reload
|
||||
}
|
||||
|
||||
export interface Failure {
|
||||
readonly state: string
|
||||
readonly cause: unknown
|
||||
}
|
||||
|
||||
type GroupedRegistration = {
|
||||
readonly remove: () => boolean
|
||||
readonly notify: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type RegistrationGroup = {
|
||||
failed: boolean
|
||||
readonly registrations: Set<GroupedRegistration>
|
||||
readonly report: (failure: Failure, refresh: Effect.Effect<void>) => void
|
||||
}
|
||||
|
||||
const CurrentGroup = Context.Reference<RegistrationGroup | undefined>("@opencode/State/CurrentGroup", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* Groups registrations without coupling State to plugin identity or asynchronous cleanup.
|
||||
* A failed group is detached synchronously; its supervisor must run refresh and close its scope.
|
||||
*/
|
||||
export function group(report: RegistrationGroup["report"]) {
|
||||
const group: RegistrationGroup = { failed: false, registrations: new Set(), report }
|
||||
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentGroup, group)
|
||||
}
|
||||
|
||||
function disable(group: RegistrationGroup, failure: Failure) {
|
||||
if (group.failed) return
|
||||
group.failed = true
|
||||
const notifications = new Set<Effect.Effect<void>>()
|
||||
for (const registration of group.registrations) {
|
||||
registration.remove()
|
||||
notifications.add(registration.notify)
|
||||
}
|
||||
group.report(
|
||||
failure,
|
||||
Effect.forEach(notifications, (notify) => notify, { discard: true }),
|
||||
)
|
||||
}
|
||||
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly shutdown: boolean
|
||||
@@ -112,19 +155,38 @@ export interface Interface<State, Editor> extends Transformable<Editor> {
|
||||
|
||||
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
let state = options.initial()
|
||||
const transforms: { run: TransformCallback<Editor> }[] = []
|
||||
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
|
||||
let dirty = false
|
||||
let closed = false
|
||||
let version = 0
|
||||
|
||||
const invalidate = () => {
|
||||
dirty = true
|
||||
version++
|
||||
}
|
||||
|
||||
const get = () => {
|
||||
if (closed || !dirty) return state
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) transform.run(editor)
|
||||
// Only a complete fold becomes visible; a throwing callback leaves the previous value and stays dirty.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
while (true) {
|
||||
const started = version
|
||||
const next = options.initial()
|
||||
const editor = options.editor(next)
|
||||
for (const transform of transforms) {
|
||||
try {
|
||||
transform.run(editor)
|
||||
} catch (cause) {
|
||||
if (!transform.group) throw cause
|
||||
disable(transform.group, { state: options.name ?? "anonymous", cause })
|
||||
}
|
||||
// A nested read can disable a group that already contributed to this candidate.
|
||||
if (version !== started) break
|
||||
}
|
||||
if (version !== started) continue
|
||||
// Ungrouped failures still propagate; grouped failures restart from a fresh candidate.
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// One stable value per State, so a batch's notification Set holds it at most once.
|
||||
@@ -137,7 +199,7 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
const changed = Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
dirty = true
|
||||
invalidate()
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (batch.shutdown) {
|
||||
@@ -156,18 +218,23 @@ export function create<State, Editor>(options: Options<State, Editor>): Interfac
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
const group = yield* CurrentGroup
|
||||
if (group?.failed) return { dispose: Effect.void }
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
const dispose = Effect.uninterruptible(
|
||||
Effect.suspend(() => {
|
||||
const index = transforms.indexOf(transform)
|
||||
if (index < 0) return Effect.void
|
||||
transforms.splice(index, 1)
|
||||
return changed
|
||||
}),
|
||||
)
|
||||
transforms.push(transform)
|
||||
const transform = { run: update, group }
|
||||
const registration: GroupedRegistration = {
|
||||
remove: () => {
|
||||
if (!transforms.delete(transform)) return false
|
||||
group?.registrations.delete(registration)
|
||||
invalidate()
|
||||
return true
|
||||
},
|
||||
notify: changed,
|
||||
}
|
||||
const dispose = Effect.uninterruptible(Effect.suspend(() => (registration.remove() ? changed : Effect.void)))
|
||||
transforms.add(transform)
|
||||
group?.registrations.add(registration)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
yield* changed
|
||||
return { dispose }
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Job } from "../../job.js"
|
||||
@@ -10,10 +10,10 @@ import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { SubagentCompletion } from "../../session/subagent-completion.js"
|
||||
import { SubagentJob } from "../../session/subagent-job.js"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundResult = (sessionID: SessionSchema.ID) => ({
|
||||
sessionID,
|
||||
status: "running" as const,
|
||||
@@ -60,42 +60,7 @@ export const Plugin = {
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
// One completion observer per job generation. Keyed by child plus start time so a fresh
|
||||
// continuation job is observable even while a settled generation's observer is finalizing.
|
||||
const notifications = new Set<string>()
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant === undefined || assistant.type !== "assistant") return NO_TEXT
|
||||
const text = assistant.content
|
||||
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
startedAt: number,
|
||||
) {
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* jobs.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(sessions, jobs, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
const subagents = yield* SubagentJob.make
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((editor) =>
|
||||
@@ -225,18 +190,10 @@ export const Plugin = {
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
}
|
||||
const info = yield* jobs.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery,
|
||||
run: sessions.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
})
|
||||
yield* subagents.start(recovery)
|
||||
|
||||
if (background) {
|
||||
yield* jobs.background(info.id)
|
||||
yield* notifyWhenDone(recovery, info.started_at)
|
||||
yield* subagents.background(recovery)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
|
||||
@@ -248,7 +205,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(recovery, result.info.started_at)
|
||||
yield* subagents.notify(recovery, result.info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
// Failure surfaces keep the sessionID visible so the model can continue the child.
|
||||
@@ -258,7 +215,11 @@ export const Plugin = {
|
||||
})
|
||||
if (result?.info.status === "cancelled")
|
||||
return yield* new ToolFailure({ message: `Subagent cancelled (sessionID: ${child.id})` })
|
||||
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "completed" as const,
|
||||
output: result?.info.output ?? SubagentCompletion.NO_TEXT,
|
||||
}
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
|
||||
@@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
? "notify"
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
@@ -172,7 +170,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
|
||||
description: command.description,
|
||||
agent: command.agent,
|
||||
model: modelSelection(command.model, command.variant),
|
||||
subtask: command.subtask,
|
||||
subagent: command.subtask,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") })
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
llmLayer,
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
LayerNodePlatform.llmClient.replace(llmLayer),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
|
||||
describe("command subagents", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "native JSON",
|
||||
format: "json",
|
||||
command: { subagent: true, agent: "build", model: "test/override" },
|
||||
agent: "build",
|
||||
model: "override",
|
||||
},
|
||||
{
|
||||
name: "legacy Markdown",
|
||||
format: "markdown",
|
||||
command: { subtask: true, agent: "build" },
|
||||
agent: "build",
|
||||
model: "parent",
|
||||
},
|
||||
{
|
||||
name: "subagent mode by default",
|
||||
format: "json",
|
||||
command: { agent: "reviewer" },
|
||||
agent: "reviewer",
|
||||
model: "child",
|
||||
},
|
||||
] as const) {
|
||||
it.live(`runs ${fixture.name} in the background without switching the parent`, () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project(fixture.command, fixture.format)
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* gate.started
|
||||
const children = (yield* sessions.list({ parentID: parent.id })).data
|
||||
expect(children).toHaveLength(1)
|
||||
const child = children[0]
|
||||
if (!child) return yield* Effect.die("Expected a child session")
|
||||
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
||||
expect(yield* sessions.context(parent.id)).toEqual([])
|
||||
expect(yield* llm.requests()).toHaveLength(1)
|
||||
expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "You are a subagent spawned by another session.\nReview changes: ready" },
|
||||
])
|
||||
yield* gate.release
|
||||
yield* llm.wait(2)
|
||||
yield* sessions.wait(parent.id)
|
||||
const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
||||
expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }])
|
||||
expect(notices[0]?.text).toContain("Review complete")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("subagent: false overrides subagent mode and the legacy alias", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json")
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
||||
yield* sessions.wait(parent.id)
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([])
|
||||
expect(yield* sessions.get(parent.id)).toMatchObject({
|
||||
agent: "reviewer",
|
||||
model: { id: "child" },
|
||||
})
|
||||
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
|
||||
{ text: "Review changes: ready" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function project(
|
||||
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
|
||||
format: "json" | "markdown",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command }
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
agents: { reviewer: { mode: "subagent", model: "test/child" } },
|
||||
...(format === "markdown" ? {} : { commands: { review: definition } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (format === "markdown")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, ".opencode/commands/review.md"),
|
||||
[
|
||||
"---",
|
||||
"description: Review code",
|
||||
...Object.entries(command).map(([key, value]) => `${key}: ${value}`),
|
||||
"---",
|
||||
definition.template,
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
return yield* sessions.create({
|
||||
location: { directory: AbsolutePath.make(tmp.path) },
|
||||
title: "Parent session",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: parentModel,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { describe, expect } from "bun:test"
|
||||
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
@@ -27,6 +30,8 @@ import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
@@ -41,12 +46,25 @@ const shellLayer = Layer.succeed(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Location.node,
|
||||
ShellSelect.node,
|
||||
Session.node,
|
||||
Job.node,
|
||||
Agent.node,
|
||||
]),
|
||||
[
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Config.node.replace(emptyConfigLayer),
|
||||
Location.node.replace(testLocationLayer),
|
||||
ShellSelect.node.replace(shellLayer),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -665,10 +666,18 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
@@ -828,30 +837,32 @@ describe("Config", () => {
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
for (const subtask of [true, false]) {
|
||||
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subagent: subtask,
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -37,7 +38,7 @@ import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node, Job.node]))),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
@@ -64,6 +64,71 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reuses running work when started again with the same ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const output = yield* Deferred.make<string>()
|
||||
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
|
||||
|
||||
expect(
|
||||
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
|
||||
).toEqual(job)
|
||||
|
||||
yield* Deferred.succeed(output, "original output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "test",
|
||||
status: "completed",
|
||||
output: "original output",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const callback = yield* Deferred.make<() => void>()
|
||||
const output = yield* Deferred.make<string>()
|
||||
const finalized = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
id: "job_replaced",
|
||||
type: "test",
|
||||
run: Effect.callback<string>((resume) => {
|
||||
Deferred.doneUnsafe(
|
||||
callback,
|
||||
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const complete = yield* Deferred.await(callback)
|
||||
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
|
||||
const replacement = yield* jobs.wait({ id: job.id }).pipe(
|
||||
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
|
||||
Effect.andThen(
|
||||
jobs.start({
|
||||
id: job.id,
|
||||
type: "replacement",
|
||||
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.sync(complete)),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
yield* Fiber.join(replacement)
|
||||
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(output, "replacement output")
|
||||
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
|
||||
type: "replacement",
|
||||
status: "completed",
|
||||
output: "replacement output",
|
||||
})
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns finished from a blocking wait when completion wins", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
||||
@@ -3,7 +3,24 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Duration, Effect, Equal, Hash, Layer, LayerMap, Option, RcMap, Schema, Stream } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
DateTime,
|
||||
Deferred,
|
||||
Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Exit,
|
||||
Fiber,
|
||||
Hash,
|
||||
Layer,
|
||||
LayerMap,
|
||||
Option,
|
||||
RcMap,
|
||||
Schema,
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -13,6 +30,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -22,7 +40,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -61,6 +79,177 @@ const itWithActivity = testEffect(
|
||||
)
|
||||
|
||||
describe("LocationServiceMap", () => {
|
||||
for (const failure of ["file", "permissions", "config reference"] as const) {
|
||||
for (const invalidate of [false, true]) {
|
||||
// The file-path fixture boots on Windows rather than failing during
|
||||
// discovery. The config-reference case covers repair on every OS.
|
||||
// Windows does not enforce POSIX directory modes, and root bypasses them.
|
||||
const test =
|
||||
(failure === "file" && process.platform === "win32") ||
|
||||
(failure === "permissions" && (process.platform === "win32" || process.getuid?.() === 0))
|
||||
? it.live.skip
|
||||
: it.live
|
||||
test(`retries after repairing ${failure}${invalidate ? " with explicit invalidation" : ""}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "repaired")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const load = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
|
||||
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "permissions") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
|
||||
}
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
const first = yield* Effect.exit(load)
|
||||
expect(Exit.isFailure(first)).toBe(true)
|
||||
if (failure === "config reference" && Exit.isFailure(first)) {
|
||||
expect(Cause.squash(first.cause)).toMatchObject({
|
||||
name: "ConfigInvalidError",
|
||||
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
|
||||
})
|
||||
}
|
||||
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
|
||||
|
||||
if (failure === "file") {
|
||||
yield* Effect.promise(() => fs.rm(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
}
|
||||
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
|
||||
if (invalidate) yield* locations.invalidate(ref)
|
||||
const repaired = yield* Effect.exit(load)
|
||||
expect(Exit.isSuccess(repaired)).toBe(true)
|
||||
// A successful graph remains cached after its last borrower releases.
|
||||
expect(yield* load).toBe(yield* repaired)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of ["file", "missing", "config reference"] as const) {
|
||||
// A file-path Location boots on Windows; use the missing config reference there.
|
||||
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
|
||||
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const directory = path.join(dir.path, "concurrent")
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
}
|
||||
|
||||
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
|
||||
Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)),
|
||||
)
|
||||
const failures = yield* Effect.forEach(
|
||||
scopes,
|
||||
(scope) => locations.contextEffect(ref).pipe(Scope.provide(scope), Effect.exit),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(failures.every(Exit.isFailure)).toBe(true)
|
||||
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
|
||||
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
const repaired = yield* locations.contextEffect(ref)
|
||||
|
||||
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
|
||||
expect(yield* locations.contextEffect(ref)).toBe(repaired)
|
||||
expect(Option.getOrThrow(yield* locations.contextEffectOption(ref))).toBe(repaired)
|
||||
}))
|
||||
}
|
||||
|
||||
for (const disposition of ["retry", "invalidate", "interrupt"] as const) {
|
||||
testEffect(Layer.empty).live(
|
||||
disposition === "invalidate"
|
||||
? "does not let an invalidated boot failure evict its replacement"
|
||||
: disposition === "interrupt"
|
||||
? "finishes a failed boot after its acquisition scopes close"
|
||||
: "shares a failed boot across acquisition APIs and retries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const builds = { started: 0 }
|
||||
const finalized: number[] = []
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationWatcher.node.replace(
|
||||
LocationWatcher.node.mapLayer((layer) =>
|
||||
layer.pipe(
|
||||
Layer.tap(() =>
|
||||
Effect.gen(function* () {
|
||||
const build = ++builds.started
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => finalized.push(build)))
|
||||
if (build !== 1) return
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
yield* Effect.die("first boot failed")
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const first = yield* locations.contextEffect(ref).pipe(Effect.scoped, Effect.exit, Effect.forkScoped)
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
yield* Deferred.await(entered)
|
||||
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
|
||||
const second = yield* Location.Service.pipe(
|
||||
Effect.provide(locations.get(ref)),
|
||||
Effect.exit,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const third = yield* locations
|
||||
.contextEffectOption(ref)
|
||||
.pipe(Scope.provide(scope), Effect.exit, Effect.forkScoped({ startImmediately: true }))
|
||||
if (disposition === "invalidate") yield* locations.invalidate(ref)
|
||||
if (disposition === "interrupt") {
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Fiber.interrupt(second)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}
|
||||
const replacement = disposition === "invalidate" ? yield* locations.contextEffect(ref) : undefined
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
if (disposition !== "interrupt") {
|
||||
expect(Exit.isFailure(yield* Fiber.join(first))).toBe(true)
|
||||
expect(Exit.isFailure(yield* Fiber.join(second))).toBe(true)
|
||||
}
|
||||
expect(Exit.isFailure(yield* Fiber.join(third).pipe(Effect.timeout("2 seconds")))).toBe(true)
|
||||
expect(finalized).toEqual([1])
|
||||
expect(builds.started).toBe(disposition === "invalidate" ? 2 : 1)
|
||||
const recovered = yield* locations.contextEffect(ref)
|
||||
if (replacement) expect(recovered).toBe(replacement)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* locations.contextEffect(ref)).toBe(recovered)
|
||||
expect(builds.started).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
expect(finalized).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
@@ -150,8 +339,8 @@ describe("LocationServiceMap", () => {
|
||||
expect(location.directory).toBe(directory)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
|
||||
// A local ref with the same missing directory keeps the existing
|
||||
// behavior: dropped as soon as it goes idle so a retry can rebuild it.
|
||||
// A local ref with the same missing directory is dropped after its
|
||||
// boot failure so a retry can rebuild it.
|
||||
yield* Location.Service.pipe(Effect.provide(locations.get(localRef)), Effect.scoped, Effect.exit)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([workspaceRef])
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.live("removes a failed plugin's hooks and RPC handlers without affecting healthy plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const invoked: string[] = []
|
||||
let fail = false
|
||||
yield* plugins.activate(
|
||||
["broken", "healthy"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
// Finalizers run in reverse order, so this signals after registration cleanup.
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => Deferred.succeed(cleaned, undefined))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("transform failed")
|
||||
})
|
||||
yield* ctx.shell.hook("create.before", () => Effect.sync(() => void invoked.push(id)))
|
||||
yield* ctx.rpc
|
||||
.register(
|
||||
Rpc.define({ id, methods: { check: { input: Schema.Struct({}), output: Schema.String } }, events: {} }),
|
||||
{ check: () => Effect.succeed(id) },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (id === "broken") yield* Effect.addFinalizer(() => plugins.awaitActivation)
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const trigger = hooks.trigger("shell", "create.before", {
|
||||
command: "echo fixture",
|
||||
cwd: ".",
|
||||
timeout: 1_000,
|
||||
shell: "sh",
|
||||
env: {},
|
||||
})
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["broken", "healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {})).toBe("broken")
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["broken", "healthy"])
|
||||
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* Deferred.await(cleaned).pipe(Effect.timeout("1 second"))
|
||||
invoked.length = 0
|
||||
yield* trigger
|
||||
expect(invoked).toEqual(["healthy"])
|
||||
expect(yield* rpc.call("broken", "check", {}).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" })
|
||||
expect(yield* rpc.call("healthy", "check", {})).toBe("healthy")
|
||||
expect((yield* commands.list()).map((command) => command.name)).toEqual(["healthy"])
|
||||
expect((yield* plugins.list()).map((plugin) => plugin.state.status)).toEqual(["failed", "active"])
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("reload", "teardown").forEach((boundary) =>
|
||||
it.live(`does not join queued failed-plugin cleanup during ${boundary}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "changing", description: "old", execute: () => Effect.void })
|
||||
if (fail) throw new Error("changing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
Effect.andThen(Deferred.succeed(cleaned, undefined)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
revision: "1",
|
||||
effect: () =>
|
||||
Effect.addFinalizer(() =>
|
||||
boundary === "teardown"
|
||||
? commands.reload().pipe(Effect.andThen(commands.list()), Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const activation = yield* (boundary === "reload" ? commands.reload() : Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
plugins.activate([
|
||||
{
|
||||
id: "changing",
|
||||
revision: "2",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) =>
|
||||
editor.add({ name: "changing", description: "new", execute: () => Effect.void }),
|
||||
)
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
]),
|
||||
),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(activation).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Allow teardown to finish even if activation incorrectly joins the old finalizer.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(activation)
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("changing")).toMatchObject({ description: "new" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not restore a disabled generation with a pending failure when its replacement fails setup", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = false
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2") yield* Effect.die("setup failed")
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (fail) throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
Array.of("pending", "reported").forEach((status) =>
|
||||
it.live(`preserves ${status} failures when an earlier plugin changes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const loads: string[] = []
|
||||
let fail = true
|
||||
const generation = (id: string, revision: string): Plugin.Generation => ({
|
||||
id,
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(`${id}@${revision}`)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (id === "broken" && fail) throw new Error("broken failed")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const broken = generation("broken", "1")
|
||||
const later = generation("later", "1")
|
||||
yield* plugins.activate([generation("earlier", "1"), broken, later])
|
||||
if (status === "reported") yield* plugins.awaitActivation
|
||||
fail = false
|
||||
yield* plugins.activate([generation("earlier", "2"), broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect(loads).toEqual(["earlier@1", "broken@1", "later@1", "earlier@2", "later@1"])
|
||||
const failed = (yield* plugins.list())[1]?.state
|
||||
expect(failed).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["earlier", "later"])
|
||||
|
||||
// Reordering and removing other plugins must preserve the same failure too.
|
||||
yield* plugins.activate([later, broken, generation("earlier", "2")])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[1]?.state).toEqual(failed)
|
||||
expect((yield* commands.list()).map((entry) => entry.name)).toEqual(["later", "earlier"])
|
||||
yield* plugins.activate([broken, later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual(failed)
|
||||
expect(loads.filter((entry) => entry === "broken@1")).toHaveLength(1)
|
||||
|
||||
yield* plugins.activate([generation("broken", "2"), later])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("broken")).toBeDefined()
|
||||
expect(loads.filter((entry) => entry === "broken@2")).toHaveLength(1)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("continues failure reporting and cleanup after a plugin update observer fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const cleaned: string[] = []
|
||||
let fail = false
|
||||
let failPublication = true
|
||||
yield* plugins.activate(
|
||||
["first", "second"].map((id) => ({
|
||||
id,
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void cleaned.push(id)))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: id, execute: () => Effect.void })
|
||||
if (fail) throw new Error(`${id} failed`)
|
||||
})
|
||||
}),
|
||||
})),
|
||||
)
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) => {
|
||||
if (event.type !== Plugin.Event.Updated.type || !failPublication) return Effect.void
|
||||
failPublication = false
|
||||
return Effect.die("observer failed")
|
||||
}),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
fail = true
|
||||
yield* commands.reload()
|
||||
const ready = yield* plugins.awaitActivation.pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
expect(Exit.isSuccess(ready)).toBe(true)
|
||||
expect((yield* plugins.list()).map((entry) => entry.state.status)).toEqual(["failed", "failed"])
|
||||
expect(cleaned.toSorted()).toEqual(["first", "second"])
|
||||
expect(yield* commands.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("settles readiness before shutdown joins a disabled plugin's finalizers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const escape = yield* Deferred.make<void>()
|
||||
let fail = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "closing",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "closing", execute: () => Effect.void })
|
||||
if (fail) throw new Error("closing failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(entered, undefined).pipe(
|
||||
Effect.andThen(plugins.awaitActivation.pipe(Effect.raceFirst(Deferred.await(escape)))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
fail = true
|
||||
const shutdown = yield* commands
|
||||
.reload()
|
||||
.pipe(Effect.andThen(plugins.close(Exit.void)), Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
const result = yield* Fiber.join(shutdown).pipe(Effect.timeout("250 millis"), Effect.exit)
|
||||
// Release the fixture even on the old implementation, rather than hanging test teardown.
|
||||
yield* Deferred.succeed(escape, undefined)
|
||||
yield* Fiber.join(shutdown)
|
||||
expect(Exit.isSuccess(result)).toBe(true)
|
||||
const release = yield* plugins.hold()
|
||||
yield* plugins.awaitActivation
|
||||
let restarted = false
|
||||
yield* plugins.activate([
|
||||
{ id: "after-close", revision: "1", effect: () => Effect.sync(() => void (restarted = true)) },
|
||||
])
|
||||
yield* release
|
||||
expect(restarted).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Clock, Effect } from "effect"
|
||||
import { Clock, Deferred, Effect } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
@@ -154,6 +156,57 @@ it.effect("reports a failed plugin without blocking a healthy plugin", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables a plugin whose transform fails after setup without publishing its partial edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const integrations = yield* Integration.Service
|
||||
let cleaned = false
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "before",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "shared", description: "original", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "broken",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (cleaned = true)))
|
||||
yield* ctx.integration.transform((editor) => editor.update("broken", (entry) => (entry.name = "Broken")))
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "shared", description: "partial", execute: () => Effect.void })
|
||||
throw new Error("replay failed")
|
||||
})
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
|
||||
yield* plugins.awaitActivation
|
||||
expect(cleaned).toBe(true)
|
||||
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform failed"),
|
||||
ref: expect.stringMatching(/^err_/),
|
||||
})
|
||||
expect(yield* commands.get("shared")).toMatchObject({ description: "original" })
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect(yield* integrations.get(Integration.ID.make("broken"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the suffix after a failed plugin alive across identical activations", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -196,6 +249,204 @@ it.effect("keeps the suffix after a failed plugin alive across identical activat
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("attributes replay failure to the broken plugin rather than a later plugin reading the registry", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "broken-plugin",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform(() => {
|
||||
throw new Error("plugin failed")
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* ctx.command.transform((editor) => editor.add({ name: "reader", execute: () => Effect.void }))
|
||||
}),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list()).map((entry) => `${entry.id}:${entry.state.status}`)).toEqual([
|
||||
"broken-plugin:failed",
|
||||
"reader:active",
|
||||
])
|
||||
expect(yield* commands.get("reader")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after runtime reload failures without retrying an unchanged generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const reported: string[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
event.type === Plugin.Event.Updated.type
|
||||
? plugins.list().pipe(
|
||||
Effect.tap((items) => Effect.sync(() => void reported.push(items[0]?.state.status ?? "empty"))),
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let fail = false
|
||||
let loads = 0
|
||||
let reload = () => Effect.void
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "runtime",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads++
|
||||
reload = ctx.command.reload
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "runtime", execute: () => Effect.void })
|
||||
if (fail) throw new Error("private failure detail")
|
||||
})
|
||||
}),
|
||||
})
|
||||
const discovery = {
|
||||
source: { type: "local" as const, path: "/missing" },
|
||||
state: { status: "failed" as const, error: "Import failed" },
|
||||
features: { server: true },
|
||||
} satisfies Plugin.Info
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
fail = true
|
||||
yield* reload()
|
||||
yield* plugins.awaitActivation
|
||||
const inventory = yield* plugins.list()
|
||||
expect(inventory[0]?.state).toMatchObject({ status: "failed", ref: expect.stringMatching(/^err_/) })
|
||||
expect(JSON.stringify(inventory[0]?.state)).not.toContain("private failure detail")
|
||||
expect(reported.at(-1)).toBe("failed")
|
||||
expect(inventory[1]).toEqual(discovery)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
|
||||
fail = false
|
||||
yield* plugins.activate([generation("1")], [discovery])
|
||||
expect(loads).toBe(1)
|
||||
expect(yield* commands.get("runtime")).toBeUndefined()
|
||||
yield* plugins.activate([generation("2")], [discovery])
|
||||
expect(loads).toBe(2)
|
||||
expect((yield* plugins.list())[0]?.state).toEqual({ status: "active" })
|
||||
expect(yield* commands.get("runtime")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables plugins after replay failures discovered during setup without restoring the old generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
const loads: string[] = []
|
||||
const generation = (revision: string): Plugin.Generation => ({
|
||||
id: "replacement",
|
||||
revision,
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
loads.push(revision)
|
||||
if (revision === "2")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({ name: "replacement", execute: () => Effect.void })
|
||||
if (revision === "2") throw new Error("replay failure")
|
||||
})
|
||||
if (revision === "2") {
|
||||
yield* ctx.command.list().pipe(Effect.orDie)
|
||||
yield* Effect.die("subsequent setup failure")
|
||||
}
|
||||
}),
|
||||
})
|
||||
yield* plugins.activate([generation("1")])
|
||||
yield* plugins.activate([generation("2")])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(loads).toEqual(["1", "2"])
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("replacement")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let asynchronous plugin cleanup block recovered registry readiness", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
const cleaned = yield* Deferred.make<void>()
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "async-cleanup",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.command.transform(() => {
|
||||
throw new Error("failed")
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
plugins.awaitActivation.pipe(Effect.andThen(Deferred.succeed(cleaned, undefined))),
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "healthy",
|
||||
revision: "1",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "healthy", execute: () => Effect.void }))
|
||||
.pipe(Effect.asVoid),
|
||||
},
|
||||
])
|
||||
yield* plugins.awaitActivation
|
||||
yield* Deferred.await(cleaned)
|
||||
expect(yield* commands.get("healthy")).toBeDefined()
|
||||
expect((yield* plugins.list())[0]?.state.status).toBe("failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains Promise plugin groups for later registrations and ignores a disabled group's attempts", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
let register = async () => {}
|
||||
const definition = fromPromise({
|
||||
id: "promise-plugin",
|
||||
setup(ctx) {
|
||||
register = async () => {
|
||||
await ctx.command.transform((editor) => {
|
||||
editor.add({ name: "late", execute: async () => {} })
|
||||
throw new Error("late Promise failure")
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* plugins.activate([{ ...definition, revision: "1" }])
|
||||
yield* Effect.promise(register)
|
||||
yield* plugins.awaitActivation
|
||||
expect((yield* plugins.list())[0]?.state).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining("command.transform"),
|
||||
})
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
yield* Effect.promise(register)
|
||||
expect(yield* commands.get("late")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloading a plugin replaces its command implementation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Rpc } from "@opencode-ai/core/rpc"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Logger, Schema } from "effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Rpc.node, [
|
||||
Location.node.replace(Layer.succeed(Location.Service, location({ directory: AbsolutePath.make("/rpc-project") }))),
|
||||
]),
|
||||
)
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
dies: { input: Schema.Undefined, output: Schema.String },
|
||||
throws: { input: Schema.Undefined, output: Schema.String },
|
||||
raw: { input: Schema.Undefined, output: Schema.String },
|
||||
undeclared: { input: Schema.Undefined, output: Schema.String },
|
||||
invalidError: {
|
||||
input: Schema.Undefined,
|
||||
output: Schema.String,
|
||||
errors: { known: Schema.Struct({ count: Schema.Int }) },
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["dies", "throws", "raw", "undeclared", "invalidError"] as const) {
|
||||
it.effect(`recovers from ${method} through the typed rpc.internal failure`, () =>
|
||||
Effect.gen(function* () {
|
||||
const rpc = yield* Rpc.Service
|
||||
yield* rpc.register(Broken, {
|
||||
dies: () => Effect.die(new Error("handler defect")),
|
||||
throws: () => {
|
||||
throw new Error("handler threw")
|
||||
},
|
||||
// Raw Promise rejections reach this boundary as failed Effects.
|
||||
// @ts-expect-error intentionally exercise an undeclared failure
|
||||
raw: () => Effect.fail(new Error("raw failure")),
|
||||
// @ts-expect-error intentionally exercise an undeclared error name
|
||||
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
|
||||
invalidError: (_input, context) => Effect.fail(context.error("known", "Invalid count", { count: 1.5 })),
|
||||
})
|
||||
const logged: unknown[] = []
|
||||
const result = yield* rpc
|
||||
.client(Broken)
|
||||
[method]()
|
||||
.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => "type" in error && error.type === "rpc.internal",
|
||||
(error) => Effect.succeed(error),
|
||||
),
|
||||
Effect.provideService(Logger.CurrentLoggers, new Set([Logger.make((entry) => logged.push(entry.message))])),
|
||||
)
|
||||
expect(result).toEqual({ type: "rpc.internal", message: "RPC call failed" })
|
||||
expect(logged).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1243,6 +1243,7 @@ describe("SessionTransfer", () => {
|
||||
const runningCompactionID = SessionMessage.ID.create()
|
||||
const completedCompactionID = SessionMessage.ID.create()
|
||||
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
|
||||
const providerState = { responseId: "summary-response" }
|
||||
|
||||
yield* transfer.import({
|
||||
data: {
|
||||
@@ -1297,6 +1298,8 @@ describe("SessionTransfer", () => {
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
model,
|
||||
providerState,
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: DateTime.makeUnsafe(9) },
|
||||
@@ -1313,6 +1316,11 @@ describe("SessionTransfer", () => {
|
||||
completedCompactionID,
|
||||
])
|
||||
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
|
||||
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
|
||||
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
|
||||
model,
|
||||
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -445,14 +445,17 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes an active exchange without waiting for its Session permit", async () => {
|
||||
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Deferred.succeed(started, undefined),
|
||||
sendText: () =>
|
||||
observed
|
||||
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
|
||||
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
|
||||
}),
|
||||
@@ -462,17 +465,30 @@ describe("SessionModelTransport", () => {
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
|
||||
const executor = transport.bind(session)
|
||||
const item = exchange("active")
|
||||
const running = yield* collect(executor, {
|
||||
...item,
|
||||
driver: {
|
||||
...item.driver,
|
||||
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
|
||||
},
|
||||
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
const queued = yield* collect(executor, exchange("queued")).pipe(
|
||||
Effect.result,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* transport.close(session)
|
||||
const result = yield* Effect.result(Fiber.join(running))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
|
||||
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
|
||||
})
|
||||
expect(yield* Fiber.join(queued)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
@@ -545,6 +561,43 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes a connection returned after its owner closes during setup", async () => {
|
||||
const connecting = Deferred.makeUnsafe<void>()
|
||||
const release = Deferred.makeUnsafe<void>()
|
||||
let closed = 0
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Deferred.succeed(connecting, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({
|
||||
sendText: () => Effect.die("Unexpected send after owner close"),
|
||||
messages: Stream.never,
|
||||
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
|
||||
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(connecting)
|
||||
yield* transport.close(session)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
|
||||
expect(yield* Fiber.join(running)).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
|
||||
})
|
||||
expect(closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir, rm } from "fs/promises"
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import { chmod, mkdir, readdir, rm } from "fs/promises"
|
||||
import { Cause, Context, Deferred, Duration, Effect, Exit, Fiber, Layer, LayerMap, Queue } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
@@ -14,10 +17,14 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionMove } from "@opencode-ai/core/session/move"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -74,8 +81,372 @@ const itWithUnavailableDestination = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const itWithExecution = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
// Windows does not enforce POSIX mode bits, and root can traverse mode-000 directories.
|
||||
const itWithPermissions =
|
||||
process.platform === "win32" || process.getuid?.() === 0 ? itWithExecution.live.skip : itWithExecution.live
|
||||
const itWithInstance = testEffect(Layer.empty)
|
||||
const sourceProbe = (options: { execution?: boolean } = {}) =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
yield* Effect.promise(() => mkdir(source))
|
||||
const probes = yield* Queue.unbounded<Deferred.Deferred<void>>()
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, Bus.node, SessionExecution.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
...(options.execution ? [] : [SessionExecution.node.replace(SessionExecution.noopLayer)]),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
return Instance.Service.of({
|
||||
provide: (session) => (effect) =>
|
||||
Effect.gen(function* () {
|
||||
if (session.location.directory === source) {
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(probes, release)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return yield* effect.pipe(Effect.provide(locations.get(session.location)))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
return {
|
||||
source,
|
||||
destination,
|
||||
probes,
|
||||
session: Context.get(context, Session.Service),
|
||||
bus: Context.get(context, Bus.Service),
|
||||
execution: Context.get(context, SessionExecution.Service),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Session.move", () => {
|
||||
itWithInstance.live("moves through the bound service without depending on the Session facade", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([SessionMove.node, SessionStore.node, Bus.node, Project.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const moves = Context.get(context, SessionMove.Service)
|
||||
const store = Context.get(context, SessionStore.Service)
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
const projects = Context.get(context, Project.Service)
|
||||
const sessionID = Session.ID.create()
|
||||
|
||||
// Call outside the construction context: the service owns all of its dependencies.
|
||||
expect(yield* moves.move({ sessionID, directory }).pipe(Effect.flip)).toEqual(
|
||||
new Session.NotFoundError({ sessionID }),
|
||||
)
|
||||
yield* projects.resolve(directory)
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
slug: "move-service",
|
||||
version: "test",
|
||||
projectID: Project.ID.global,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "missing")) }),
|
||||
})
|
||||
yield* moves.move({ sessionID, directory })
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
location: { directory },
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstance.live("delegates to the provided move service", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = AbsolutePath.make(tmp.path)
|
||||
const rejection = new Session.DestinationUnavailableError({ directory })
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(Session.node, [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionMove.node.replace(Layer.succeed(SessionMove.Service, { move: () => Effect.fail(rejection) })),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const sessions = Context.get(context, Session.Service)
|
||||
const created = yield* sessions.create({ location: Location.Ref.make({ directory }) })
|
||||
|
||||
expect(yield* sessions.move({ sessionID: created.id, directory }).pipe(Effect.flip)).toBe(rejection)
|
||||
expect(yield* sessions.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithExecution.live(
|
||||
`moves an idle session from ${broken ? "broken" : "healthy"} source configuration`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
if (broken)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
itWithPermissions(
|
||||
"recovers an idle session from an unreadable source directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => chmod(source, 0o755)))
|
||||
yield* Effect.promise(() => chmod(source, 0o000))
|
||||
expect(
|
||||
yield* Effect.promise(() =>
|
||||
readdir(source).then(
|
||||
() => false,
|
||||
() => true,
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
yield* execution.awaitIdle(created.id)
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
for (const broken of [false, true]) {
|
||||
itWithInstance.live(
|
||||
`uses the ${broken ? "broken" : "healthy discovery-disabled"} selected instance rather than the default Location`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
|
||||
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
|
||||
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
|
||||
const selectedID = Session.ID.create()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
Instance.node.replace(
|
||||
makeGlobalNode({
|
||||
service: Instance.Service,
|
||||
deps: [LocationServiceMap.node],
|
||||
layer: Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const privateInstances = yield* LayerMap.make(
|
||||
() =>
|
||||
Instance.layer(source, {
|
||||
discovery: false,
|
||||
replacements: [
|
||||
...bindings,
|
||||
...(broken
|
||||
? [
|
||||
Config.node.replace(
|
||||
Config.configured({ project: false, global: false, content: config }),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
const selector = Instance.Service.of({
|
||||
provide: (session) =>
|
||||
Effect.provide(
|
||||
session.id === selectedID && session.location.directory === source.directory
|
||||
? privateInstances.get(session.id)
|
||||
: locations.get(session.location),
|
||||
),
|
||||
})
|
||||
const bindings: LayerNode.Replacements = [
|
||||
...replacements,
|
||||
Instance.node.replace(Layer.succeed(Instance.Service, selector)),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
|
||||
]
|
||||
return selector
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(Session.node, replacements))
|
||||
const session = Context.get(context, Session.Service)
|
||||
const created = yield* session.create({ id: selectedID, location: source })
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep pending",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(broken ? destination : source.directory)
|
||||
const inbox = yield* session.inbox(created.id)
|
||||
expect(inbox[0]).toEqual(pending)
|
||||
if (broken) expect(inbox).toEqual([pending])
|
||||
if (!broken) expect(inbox.slice(1)).toMatchObject([{ type: "move", delivery: "queue" }])
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
for (const interrupt of ["source", "caller"] as const) {
|
||||
itWithInstance.live(`does not recover or enqueue a move when the ${interrupt} interrupts the probe`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({ sessionID: created.id, text: "Keep pending", resume: false })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
if (interrupt === "source") yield* Deferred.interrupt(release)
|
||||
if (interrupt === "caller") yield* Fiber.interrupt(moving)
|
||||
const exit = yield* Fiber.await(moving)
|
||||
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([pending])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithInstance.live("does not recover if execution starts during the source probe", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe({ execution: true })
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.execution.wake(created.id)
|
||||
// The real coordinator now owns execution; its separate instance acquisition stays suspended.
|
||||
yield* Queue.take(fixture.probes)
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
yield* Fiber.join(moving)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location.directory).toBe(fixture.source)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([{ type: "move", delivery: "steer" }])
|
||||
expect(yield* fixture.execution.isActive(created.id)).toBe(true)
|
||||
yield* fixture.execution.interrupt(created.id)
|
||||
yield* fixture.execution.awaitIdle(created.id)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
itWithInstance.live(
|
||||
"recovers a missing source without initializing its instance and retains destination workspace identity",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({
|
||||
location: Location.Ref.make({ directory: fixture.source, workspaceID: Workspace.ID.create() }),
|
||||
})
|
||||
yield* Effect.promise(() => rm(fixture.source, { recursive: true }))
|
||||
const workspaceID = Workspace.ID.create()
|
||||
|
||||
yield* fixture.session.move({ sessionID: created.id, directory: fixture.destination, workspaceID })
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(
|
||||
Location.Ref.make({ directory: fixture.destination, workspaceID }),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(yield* Queue.size(fixture.probes)).toBe(0)
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
|
||||
for (const changed of ["directory", "workspace"] as const) {
|
||||
itWithInstance.live(`allows inbox cancellation during a source probe and rejects stale ${changed} recovery`, () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sourceProbe()
|
||||
const created = yield* fixture.session.create({ location: Location.Ref.make({ directory: fixture.source }) })
|
||||
const pending = yield* fixture.session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Cancel pending",
|
||||
resume: false,
|
||||
})
|
||||
const moving = yield* fixture.session
|
||||
.move({ sessionID: created.id, directory: fixture.destination })
|
||||
.pipe(Effect.exit, Effect.forkScoped)
|
||||
const release = yield* Queue.take(fixture.probes)
|
||||
|
||||
yield* fixture.session.cancelInbox({ sessionID: created.id, inboxID: pending.id }).pipe(
|
||||
Effect.timeout("2 seconds"),
|
||||
Effect.onError(() => Deferred.interrupt(release)),
|
||||
)
|
||||
expect(yield* fixture.session.inbox(created.id)).toEqual([])
|
||||
expect(moving.pollUnsafe()).toBeUndefined()
|
||||
const location = Location.Ref.make({
|
||||
directory: changed === "directory" ? fixture.destination : fixture.source,
|
||||
workspaceID: changed === "workspace" ? Workspace.ID.create() : undefined,
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Moved, {
|
||||
sessionID: created.id,
|
||||
location,
|
||||
projectID: created.projectID,
|
||||
})
|
||||
yield* Deferred.die(release, new Error("source unavailable"))
|
||||
expect(Exit.isSuccess(yield* Fiber.join(moving))).toBe(true)
|
||||
|
||||
expect((yield* fixture.session.get(created.id)).location).toEqual(location)
|
||||
expect(yield* fixture.session.inbox(created.id)).toMatchObject([
|
||||
{ type: "move", payload: { location: { directory: fixture.destination } } },
|
||||
])
|
||||
}).pipe(Effect.timeout("5 seconds")),
|
||||
)
|
||||
}
|
||||
|
||||
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
|
||||
tmpdirScoped().pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
@@ -111,15 +482,23 @@ describe("Session.move", () => {
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(AbsolutePath.make(source))
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
const pending = yield* session.synthetic({
|
||||
sessionID: created.id,
|
||||
text: "Keep queued",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
yield* session.move({ sessionID: created.id, directory: destination, delivery: "queue" })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(3)
|
||||
|
||||
yield* Effect.promise(() => rm(source, { recursive: true }))
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
expect(yield* session.inbox(created.id)).toEqual([])
|
||||
expect(yield* session.inbox(created.id)).toEqual([pending])
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(2)
|
||||
|
||||
yield* Effect.promise(() => mkdir(path.join(tmp.path, "other")))
|
||||
const steered = yield* session.create({
|
||||
|
||||
@@ -2141,7 +2141,13 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }),
|
||||
TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000),
|
||||
TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"),
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "stop" },
|
||||
providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } },
|
||||
},
|
||||
LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }),
|
||||
),
|
||||
)
|
||||
yield* s.runPrompt("Review these changes")
|
||||
if (reason === "manual") {
|
||||
@@ -2177,6 +2183,10 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.")
|
||||
expect(requestAgents[2]).toBe(Agent.ID.make("compaction"))
|
||||
expect(s.executions).toEqual(["x".repeat(4_000)])
|
||||
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
|
||||
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
|
||||
providerState: { responseId: "summary" },
|
||||
})
|
||||
|
||||
// Compare wire content without the cache breakpoints that move to the new final message.
|
||||
const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" }))
|
||||
@@ -2209,8 +2219,14 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
: TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary")
|
||||
yield* s.llm.push(
|
||||
invalid,
|
||||
summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid,
|
||||
invalid.map((event) =>
|
||||
LLMEvent.is.stepFinish(event)
|
||||
? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } }
|
||||
: event,
|
||||
),
|
||||
summary
|
||||
? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })]
|
||||
: invalid,
|
||||
)
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
@@ -2220,6 +2236,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template")
|
||||
expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true)
|
||||
expect(s.executions).toEqual([])
|
||||
expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject(
|
||||
summary
|
||||
? { status: "completed", summary: "### Active\n- Recovered summary" }
|
||||
|
||||
@@ -148,7 +148,9 @@ for (const fixture of [
|
||||
.all()
|
||||
const types = events.map((event) => event.type)
|
||||
const terminal = fixture.finish === "stop" ? "session.step.ended.1" : "session.step.failed.1"
|
||||
expect(types.filter((type) => type === "session.step.streamed.1")).toHaveLength(1)
|
||||
expect(types.filter((type) => type === terminal)).toHaveLength(1)
|
||||
expect(types.indexOf("session.step.streamed.1")).toBeLessThan(types.indexOf(terminal))
|
||||
expect(
|
||||
types.indexOf(fixture.toolChoice === "none" ? "session.tool.failed.2" : "session.tool.success.2"),
|
||||
).toBeLessThan(types.indexOf(terminal))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if true; then ${source}; fi`,
|
||||
(source: string) => `outer() { ${source}; }; outer`,
|
||||
]
|
||||
|
||||
const bodies = [
|
||||
"for value in one two; do scan_probe; done",
|
||||
"while true; do scan_probe; break; done",
|
||||
"until false; do scan_probe; break; done",
|
||||
"case value in value) scan_probe;; *) scan_other;; esac",
|
||||
]
|
||||
|
||||
describe("compound function acceptance", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
for (const head of ["probe()", "function probe", "function probe()", "probe-name()"])
|
||||
for (const body of bodies)
|
||||
for (const context of contexts) {
|
||||
const name = head.includes("probe-name") ? "probe-name" : "probe"
|
||||
const source = context(`${head} ${body}; ${name}`)
|
||||
test(`${shell}: ${source}`, async () => {
|
||||
// Braces preserve the function's behavior, but avoid Tree-sitter's recovery artifacts.
|
||||
const legacy = await Effect.runPromise(
|
||||
ShellParse.scan(context(`${head} { ${body}; }; ${name}`), shell, "/workspace"),
|
||||
)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test.each(bodies)("keeps compound function bodies inside command substitutions: %s", (body) => {
|
||||
const source = `printf '%s' "$( probe() ${body}; probe )"`
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands[0]?.resource).toBe(source)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
expect(result.commands.at(-1)?.words).toEqual(["probe"])
|
||||
})
|
||||
})
|
||||
|
||||
const values = [
|
||||
"one two",
|
||||
"'two words' one",
|
||||
"'cd' '/outside'",
|
||||
"'do' 'done'",
|
||||
"'(literal)' '$(scan_ignored)'",
|
||||
"one\\\ntwo",
|
||||
"$(printf one)",
|
||||
'"$(printf one)"',
|
||||
"<(printf one)",
|
||||
"",
|
||||
]
|
||||
const loops = values.flatMap((value) =>
|
||||
[
|
||||
`for value (${value}) scan_probe "$value"`,
|
||||
`for value (${value}) { scan_probe "$value"; }`,
|
||||
...(value
|
||||
? [
|
||||
`for value (${value}) do scan_probe "$value"; done`,
|
||||
`for value (${value}); do scan_probe "$value"; done`,
|
||||
`for value (${value})\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) # ignored\ndo scan_probe "$value"; done`,
|
||||
`for value (${value}) \\\ndo scan_probe "$value"; done`,
|
||||
]
|
||||
: []),
|
||||
].map((source) => ({ source, equivalent: `for value in ${value}; do scan_probe "$value"; done` })),
|
||||
)
|
||||
|
||||
describe("Zsh parenthesized loop acceptance", () => {
|
||||
for (const fixture of loops)
|
||||
for (const context of contexts) {
|
||||
const source = context(fixture.source)
|
||||
test(source, async () => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(context(fixture.equivalent), "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"for x (one two) for y (a b) scan_probe",
|
||||
"for x (one two) scan_probe && scan_other",
|
||||
"for x (one two) scan_probe | scan_other",
|
||||
"printf '%s' \"$(for x (one two) scan_probe)\"",
|
||||
"for x (one two) { for y (a b); do scan_probe; done; }",
|
||||
"for x (one two) [[ $(scan_probe) == ok ]]",
|
||||
"for x (one two) (( 1 + $(scan_probe) ))",
|
||||
])("retains commands in nested shorthand loops: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
if (source.includes("scan_other"))
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-shell compound syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
...bodies.map((body) => `probe() ${body}; probe`),
|
||||
...bodies.map((body) => `printf '%s' "$(probe() ${body}; probe)"`),
|
||||
...(shell === "zsh" ? loops.map((fixture) => fixture.source) : []),
|
||||
])(`${shell}: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, timeout: 2_000 },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toEqual(
|
||||
source.includes("value ()") ? "" : expect.stringContaining("executed\n"),
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const conditions = ["[[ -n <(scan_probe) ]]", "[[ -n >(scan_probe) ]]"]
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `( ${source} )`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `if ${source}; then printf visible; fi`,
|
||||
(source: string) => `check() { ${source}; }; check`,
|
||||
(source: string) => `printf '%s' "$( ${source}; printf visible)"`,
|
||||
]
|
||||
|
||||
const functions = ["probe", "probe-name", "probe.name", "probe:name"].flatMap((name) =>
|
||||
[`${name}()`, `function ${name}`, `function ${name}()`].flatMap((head) =>
|
||||
[
|
||||
"{ scan_probe; }",
|
||||
"(scan_probe)",
|
||||
"if true; then scan_probe; fi",
|
||||
"[[ $(scan_probe) == ok ]]",
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
].map((body) => `${head} ${body}; ${name}`),
|
||||
),
|
||||
)
|
||||
|
||||
describe("legacy-accepted shell syntax regressions", () => {
|
||||
test.each(["() { scan_probe; }", "probe() { scan_probe; }; probe"])(
|
||||
"preserves deeply indented function definitions: %s",
|
||||
async (source) => {
|
||||
const command = `( ${" ".repeat(32_000)}${source} )`
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "zsh", "/workspace"))
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "zsh", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(conditions.flatMap((source) => contexts.map((context) => context(source))))(
|
||||
"retains conditional process substitutions and permission resources: %s",
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "bash", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "bash", "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
test.each(
|
||||
["probe()", "probe \\\n()", "function \\\nprobe()", "function probe \\\n()"].flatMap((head) =>
|
||||
[" \\\n", " \\\n # ignored ) }\n", "# ignored \\\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves line continuations at function boundaries: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(
|
||||
["probe()", "function probe", "function probe()"].flatMap((head) =>
|
||||
[" # ignored ) }\n", "\n# ignored ) }\n\n", " # first\n# second\n"].flatMap((gap) =>
|
||||
contexts.map((context) => context(`${head}${gap}{ scan_probe; }; probe`)),
|
||||
),
|
||||
),
|
||||
)(`${shell} preserves comments between a function head and its body: %s`, async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
test.each(functions)(
|
||||
`${shell} preserves function resources, saved prefixes, and directories: %s`,
|
||||
async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, shell, "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, shell, "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
"probe-name() { scan_probe; }; probe-name",
|
||||
"function probe.name { scan_probe; }; probe.name",
|
||||
"probe:name() if true; then scan_probe; fi; probe:name",
|
||||
"probe()# ignored ) }\n{ scan_probe; }; probe",
|
||||
"probe \\\n() \\\n{ scan_probe; }; probe",
|
||||
"function \\\nprobe() # ignored \\\n{ scan_probe; }; probe",
|
||||
...(shell === "bash" ? conditions : ["() { scan_probe; }"]),
|
||||
])(`${shell} really executes the extracted command: %s`, (source) => {
|
||||
if (!executable) throw new Error(`${shell} is unavailable`)
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}; wait`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stderr.toString()).toBe("executed\n")
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"() { scan_probe; }",
|
||||
"( () { scan_probe; } )",
|
||||
"{ () { scan_probe; }; }",
|
||||
"while() { scan_probe; break; }",
|
||||
"until() { scan_probe; break; }",
|
||||
])("preserves Zsh anonymous functions and parenthesized loop permissions: %s", async (source) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.some((command) => command.resource === "scan_probe")).toBe(true)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(source, "zsh", "/workspace"))).toEqual(legacy)
|
||||
})
|
||||
|
||||
// Tree-sitter recovers these valid Zsh forms with synthetic commands or truncated outer resources.
|
||||
// Pin both results rather than treating recovery artifacts as executable shell syntax.
|
||||
test.each([
|
||||
{
|
||||
source: "if () { scan_probe; }; then printf visible; fi",
|
||||
legacy: ["scan_probe", "then printf visible", "fi"],
|
||||
portable: ["scan_probe", "printf visible"],
|
||||
},
|
||||
{
|
||||
source: "check() { () { scan_probe; }; }; check",
|
||||
legacy: ["scan_probe", "}", "check"],
|
||||
portable: ["scan_probe", "check"],
|
||||
},
|
||||
{
|
||||
source: "printf '%s' \"$( () { scan_probe; }; printf visible)\"",
|
||||
legacy: ["printf '%s'", "scan_probe", "printf visible"],
|
||||
portable: ["printf '%s' \"$( () { scan_probe; }; printf visible)\"", "scan_probe", "printf visible"],
|
||||
},
|
||||
])("accepts anonymous-function compositions despite legacy recovery artifacts: $source", async (fixture) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(fixture.source, "zsh", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scanPortable(fixture.source, "zsh", "/workspace"))
|
||||
expect(legacy.commands.map((command) => command.resource)).toEqual([...fixture.legacy])
|
||||
expect(portable.commands.map((command) => command.resource)).toEqual([...fixture.portable])
|
||||
})
|
||||
|
||||
test.each([
|
||||
"[[ -n '<(scan_ignored)' ]]",
|
||||
'[[ -n "<(scan_ignored)" ]]',
|
||||
"[[ -n '>(scan_ignored)' ]]",
|
||||
'[[ -n ">(scan_ignored)" ]]',
|
||||
"[[ -n $'<(scan_ignored)' ]]",
|
||||
])("does not turn quoted process-substitution text into commands: %s", (source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,30 @@ import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const pwsh = process.env.SHELL_SCAN_PWSH ?? Bun.which("pwsh")
|
||||
|
||||
// These ordinary forms must stay accepted, not disappear behind the oracle's opaque-result filter.
|
||||
const supported = [
|
||||
"Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"if (Invoke-ProbeA) { Invoke-ProbeB } else { Invoke-ProbeC }",
|
||||
"foreach ($item in (Invoke-ProbeA)) { Invoke-ProbeB }",
|
||||
"function Get-Probe { param($x); Invoke-ProbeB }; Invoke-ProbeA",
|
||||
"$x = @{ first = Invoke-ProbeA; second = @(Invoke-ProbeB; Invoke-ProbeC) }",
|
||||
'Invoke-ProbeA "$(Invoke-ProbeB "$(Invoke-ProbeC)")"',
|
||||
"Invoke-ProbeA | ForEach-Object { Invoke-ProbeB }",
|
||||
"Invoke-ProbeA @'\nliteral ; }\n'@; Invoke-ProbeB",
|
||||
'Invoke-ProbeA @"\n$(Invoke-ProbeB)\n"@; Invoke-ProbeC',
|
||||
"Invoke-ProbeA `\n argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA 2>&1; Invoke-ProbeB",
|
||||
"& 'Invoke-ProbeA' argument; Invoke-ProbeB",
|
||||
"Invoke-ProbeA --% literal; ignored\nInvoke-ProbeB",
|
||||
]
|
||||
|
||||
test.each(supported)("accepts supported PowerShell syntax without an opaque escape hatch: %s", (source) => {
|
||||
expect(ShellScan.scanPowerShell(source).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
const fixtures = [
|
||||
...supported,
|
||||
...[
|
||||
"$result = Invoke-ProbeA; Invoke-ProbeB",
|
||||
"$result = (Invoke-ProbeA); Invoke-ProbeB",
|
||||
@@ -343,6 +366,10 @@ test.skipIf(!pwsh)(
|
||||
let executed = 0
|
||||
for (const result of results) {
|
||||
const scan = ShellScan.scanPowerShell(result.source)
|
||||
if (supported.includes(result.source)) {
|
||||
expect(result.errors, result.source).toEqual([])
|
||||
expect(scan.kind, result.source).toBe("scanned")
|
||||
}
|
||||
if (scan.kind === "opaque" || result.errors.length > 0) continue
|
||||
scanned++
|
||||
executed += result.executed.length
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
it.effect("detaches every registration of a failed group and refreshes every affected domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const notices: string[] = []
|
||||
const failures: State.Failure[] = []
|
||||
let refresh = Effect.void
|
||||
let fail = false
|
||||
let calls = 0
|
||||
const grouped = State.group((failure, changed) => {
|
||||
failures.push(failure)
|
||||
refresh = changed
|
||||
})
|
||||
const first = State.create({
|
||||
name: "first",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("first")),
|
||||
})
|
||||
const second = State.create({
|
||||
name: "second",
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
editor: (value) => value,
|
||||
notify: () => Effect.sync(() => void notices.push("second")),
|
||||
})
|
||||
yield* first.transform((editor) => editor.values.push("healthy"))
|
||||
const registration = yield* first.transform((editor) => editor.values.push("grouped")).pipe(grouped)
|
||||
yield* first.transform((editor) => editor.values.push("also grouped")).pipe(grouped)
|
||||
yield* second
|
||||
.transform((editor) => {
|
||||
calls++
|
||||
editor.values.push("partial")
|
||||
if (fail) throw new Error("broken")
|
||||
})
|
||||
.pipe(grouped)
|
||||
const before = first.get()
|
||||
notices.length = 0
|
||||
fail = true
|
||||
yield* second.reload()
|
||||
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(second.get().values).toEqual([])
|
||||
expect(before.values).toEqual(["healthy", "grouped", "also grouped"])
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(failures[0]?.state).toBe("second")
|
||||
expect(calls).toBe(2)
|
||||
|
||||
notices.length = 0
|
||||
// The group deduplicates its domain notifications without relying on an outer batch.
|
||||
yield* refresh
|
||||
expect(notices.toSorted()).toEqual(["first", "second"])
|
||||
yield* registration.dispose
|
||||
expect(notices).toHaveLength(2)
|
||||
yield* first.transform((editor) => editor.values.push("resurrected")).pipe(grouped)
|
||||
expect(first.get().values).toEqual(["healthy"])
|
||||
expect(failures).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restarts an outer candidate when a nested read disables one of its contributors", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
const grouped = State.group(() => {})
|
||||
const inner = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
const outer = State.create({ initial: () => ({ value: 0 }), editor: (value) => value })
|
||||
yield* outer.transform((editor) => (editor.value += 10)).pipe(grouped)
|
||||
yield* inner
|
||||
.transform((editor) => {
|
||||
editor.value = 5
|
||||
if (fail) throw new Error("inner failed")
|
||||
})
|
||||
.pipe(grouped)
|
||||
yield* outer.transform((editor) => (editor.value += inner.get().value + 1))
|
||||
expect(outer.get().value).toBe(16)
|
||||
|
||||
fail = true
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* inner.reload()
|
||||
yield* outer.reload()
|
||||
expect(outer.get().value).toBe(1)
|
||||
expect(inner.get().value).toBe(0)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables multiple failing groups once each before publishing a complete fold", () =>
|
||||
Effect.gen(function* () {
|
||||
const reported: string[] = []
|
||||
const first = State.group(() => reported.push("first"))
|
||||
const second = State.group(() => reported.push("second"))
|
||||
const state = State.create({ initial: () => ({ values: [] as string[] }), editor: (value) => value })
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("first")
|
||||
throw "first failed"
|
||||
})
|
||||
.pipe(first)
|
||||
yield* state
|
||||
.transform((editor) => {
|
||||
editor.values.push("second")
|
||||
throw { message: "second failed" }
|
||||
})
|
||||
.pipe(second)
|
||||
yield* state.transform((editor) => editor.values.push("healthy"))
|
||||
}),
|
||||
)
|
||||
expect(state.get().values).toEqual(["healthy"])
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
yield* state.reload()
|
||||
expect(reported).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable a group for a notification failure after successful replay", () =>
|
||||
Effect.gen(function* () {
|
||||
let reported = 0
|
||||
let fail = true
|
||||
const grouped = State.group(() => reported++)
|
||||
const state = State.create({
|
||||
initial: () => ({ value: 0 }),
|
||||
editor: (value) => value,
|
||||
notify: () => (fail ? Effect.die("observer failed") : Effect.void),
|
||||
})
|
||||
yield* state.transform((editor) => editor.value++).pipe(grouped, Effect.exit)
|
||||
expect(reported).toBe(0)
|
||||
expect(state.get().value).toBe(1)
|
||||
fail = false
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -526,6 +526,150 @@ describe("ShellTool scanner permissions", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool conditional process substitution", () => {
|
||||
const test = isWindows || !Bun.which("bash") ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${portable ? "native" : "legacy"}: a nested deny prevents the substitution from running`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(toolIdentity.agent, (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "printf *", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
'[[ -n <(printf reached > marker) ]]; wait "$!"',
|
||||
marker,
|
||||
[],
|
||||
)
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "error", error: { message: expect.stringContaining("Permission denied: shell") } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
|
||||
for (const reply of ["reject", "once", "always"] as const) {
|
||||
test(`${portable ? "native" : "legacy"}: conditional substitutions respect ${reply}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: ["wait *"] })
|
||||
const marker = path.join(directory.active, "marker")
|
||||
const command = '[[ -n <(printf reached > marker) ]]; wait "$!"'
|
||||
const result = yield* runPermissionCommand(registry, command, marker, [reply])
|
||||
expect(result.requests).toMatchObject([
|
||||
{ action: "shell", resources: ["printf reached > marker", 'wait "$!"'], save: ["printf *", "wait *"] },
|
||||
])
|
||||
if (reply === "reject") {
|
||||
expect(Exit.isFailure(result.exit)).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(false)
|
||||
return
|
||||
}
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: { status: "completed", metadata: { exit: 0 } },
|
||||
})
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).text())).toBe("reached")
|
||||
yield* Effect.promise(() => fs.unlink(marker))
|
||||
const repeat = yield* runPermissionCommand(
|
||||
registry,
|
||||
command,
|
||||
marker,
|
||||
reply === "always" ? [] : ["reject"],
|
||||
)
|
||||
expect(repeat.requests).toHaveLength(reply === "always" ? 0 : 1)
|
||||
expect(yield* Effect.promise(() => Bun.file(marker).exists())).toBe(reply === "always")
|
||||
}),
|
||||
"bash",
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool compound syntax approval compatibility", () => {
|
||||
for (const fixture of [
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) printf %s "$value"',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value (a b) { printf %s "$value"; }',
|
||||
equivalent: 'for value in a b; do printf %s "$value"; done',
|
||||
output: "ab",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "zsh",
|
||||
command: 'for value ($(printf a)) do printf %s "$value"; done',
|
||||
equivalent: 'for value in $(printf a); do printf %s "$value"; done',
|
||||
output: "a",
|
||||
saved: ["printf *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'probe() for value in a b; do printf %s "$value"; done; probe',
|
||||
equivalent: 'probe() { for value in a b; do printf %s "$value"; done; }; probe',
|
||||
output: "ab",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
{
|
||||
shell: "bash",
|
||||
command: 'printf %s "$(probe() case value in value) printf hello;; esac; probe)"',
|
||||
equivalent: 'printf %s "$(probe() { case value in value) printf hello;; esac; }; probe)"',
|
||||
output: "hello",
|
||||
saved: ["printf *", "probe *"],
|
||||
},
|
||||
]) {
|
||||
const test = isWindows || !Bun.which(fixture.shell) ? permissionIt.live.skip : permissionIt.live
|
||||
for (const portable of [false, true]) {
|
||||
test(`${fixture.shell} ${portable ? "native" : "legacy equivalent"}: ${fixture.command}`, () =>
|
||||
withScanner(
|
||||
portable,
|
||||
(registry, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const location = yield* Location.Service
|
||||
yield* saved.add({ projectID: location.project.id, action: "shell", resources: fixture.saved })
|
||||
const result = yield* runPermissionCommand(
|
||||
registry,
|
||||
portable ? fixture.command : fixture.equivalent,
|
||||
path.join(directory.active, "marker"),
|
||||
[],
|
||||
)
|
||||
expect(result.requests).toEqual([])
|
||||
expect(result.exit).toMatchObject({
|
||||
_tag: "Success",
|
||||
value: {
|
||||
status: "completed",
|
||||
metadata: { exit: 0 },
|
||||
content: [{ type: "text", text: fixture.output }, { type: "text" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
fixture.shell,
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("ShellTool ordinary shell syntax", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const test = isWindows || !Bun.which(shell) ? permissionIt.live.skip : permissionIt.live
|
||||
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
@@ -13899,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install automatically",
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -9,5 +9,6 @@ export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
description: Schema.String.pipe(optional),
|
||||
agent: Schema.String.pipe(optional),
|
||||
model: ConfigModel.Selection.pipe(optional),
|
||||
subtask: Schema.Boolean.pipe(optional),
|
||||
subagent: Schema.Boolean.pipe(optional),
|
||||
subtask: Schema.Boolean.annotate({ description: "Deprecated alias for subagent." }).pipe(optional),
|
||||
}) {}
|
||||
|
||||
@@ -585,6 +585,8 @@ export namespace Compaction {
|
||||
schema: {
|
||||
...Base,
|
||||
reason: Started.data.fields.reason,
|
||||
model: SessionMessage.CompactionCompleted.fields.model,
|
||||
providerState: SessionMessage.CompactionCompleted.fields.providerState,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
},
|
||||
|
||||
@@ -250,6 +250,8 @@ export const CompactionCompleted = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("completed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
model: Model.Ref.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/effect"
|
||||
import type { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
import type { SdkInstances } from "../internal/instances"
|
||||
|
||||
@@ -35,9 +35,12 @@ export const create: <R = never>(
|
||||
R = never,
|
||||
>(options: CreateOptions<R> = {}, embed: EmbedOptions = {}) {
|
||||
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
|
||||
const httpClient = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
|
||||
Effect.provide(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, host.fetch)), Layer.fresh),
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
// FetchHttpClient reads Fetch at request time; callers must not replace this host's in-process transport.
|
||||
HttpClient.transformResponse(httpClient, Effect.provideService(FetchHttpClient.Fetch, host.fetch)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { AbsolutePath, Location, OpenCode, Session } from "../src/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
for (const entrypoint of ["create", "layer"] as const) {
|
||||
it.live(`${entrypoint} keeps requests and streams on its own transport despite an ambient Fetch`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const calls: string[] = []
|
||||
const ambient = Object.assign(
|
||||
(input: RequestInfo | URL) => {
|
||||
calls.push(input instanceof Request ? input.url : String(input))
|
||||
return Promise.reject(new Error("The caller's Fetch must not receive embedded SDK requests"))
|
||||
},
|
||||
{ preconnect: () => undefined },
|
||||
)
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const options: OpenCode.CreateOptions = {
|
||||
app: { version: "transport-test" },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
events: { persist: true },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
}
|
||||
const client = yield* (
|
||||
entrypoint === "create"
|
||||
? OpenCode.create(options).pipe(Scope.provide(scope))
|
||||
: Layer.buildWithScope(OpenCode.layer(options), scope).pipe(Effect.map(Context.get(OpenCode.Service)))
|
||||
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* client.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
|
||||
const session = yield* client.sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
})
|
||||
expect((yield* client.sessions.get({ sessionID: session.id })).id).toBe(session.id)
|
||||
const events = yield* client.sessions.log({ sessionID: session.id }).pipe(Stream.runCollect)
|
||||
expect(events.some((event) => event.type === "session.created")).toBe(true)
|
||||
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
|
||||
{ type: "server.connected" },
|
||||
])
|
||||
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "SessionNotFoundError",
|
||||
})
|
||||
// Binding the SDK's transport must not change the caller's surrounding context.
|
||||
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
|
||||
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
|
||||
expect(calls).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* client.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
return output === undefined ? {} : { output }
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error.type === "rpc.invalid_output"
|
||||
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
|
||||
? new RpcInternalError({ type: error.type, message: error.message })
|
||||
: new RpcError({
|
||||
type: error.type,
|
||||
@@ -22,12 +22,10 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
}),
|
||||
),
|
||||
Effect.catchDefect((error) =>
|
||||
Effect.fail(
|
||||
new RpcInternalError({
|
||||
type: "rpc.internal",
|
||||
message: error instanceof Error ? error.message : "RPC call failed",
|
||||
}),
|
||||
// Defects outside handler execution are still logged, never echoed to the client.
|
||||
Effect.catchDefect((defect) =>
|
||||
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
|
||||
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
@@ -116,12 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from "bun:test"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
@@ -100,12 +99,9 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
expect(event.status).toBe(200)
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
const reader = event.body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -130,11 +126,3 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect } from "bun:test"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Rpc } from "@opencode-ai/schema/rpc"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { createEmbeddedRoutes } from "../src/routes"
|
||||
|
||||
const Broken = Rpc.define({
|
||||
id: "broken",
|
||||
methods: {
|
||||
handler: { input: Schema.String, output: Schema.String },
|
||||
schema: {
|
||||
input: Schema.String.check(
|
||||
Schema.makeFilter(() => {
|
||||
throw new Error("private schema detail")
|
||||
}),
|
||||
),
|
||||
output: Schema.String,
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
for (const method of ["handler", "schema"] as const) {
|
||||
it.live(`returns HTTP 500 without exposing the ${method} defect`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const context = yield* Layer.build(
|
||||
createEmbeddedRoutes({
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
fs: { filewatcher: false },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
const sdk = Context.get(context, SdkPlugins.Service)
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "broken-rpc",
|
||||
effect: (ctx) =>
|
||||
ctx.rpc
|
||||
.register(Broken, {
|
||||
handler: () => Effect.die(new Error("private handler detail")),
|
||||
schema: Effect.succeed,
|
||||
})
|
||||
.pipe(Effect.asVoid, Effect.orDie),
|
||||
}),
|
||||
)
|
||||
const handler = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(HttpEffect.toWebHandlerWith(context))
|
||||
const url = new URL(`/api/rpc/broken/${method}`, "http://opencode.local")
|
||||
url.searchParams.set("location[directory]", directory.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input: "hello" }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "RpcInternalError",
|
||||
type: "rpc.internal",
|
||||
message: "RPC call failed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
+50
-32
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { SessionPanelProvider } from "./context/session-panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -186,6 +187,7 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
packages: PackageSource
|
||||
@@ -220,9 +222,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
// Give the server a chance to respawn itself before starting client-side recovery.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next), url: endpoint.url }
|
||||
@@ -399,22 +398,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<SessionPanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</SessionPanelProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -507,6 +508,36 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -1215,19 +1246,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
})
|
||||
})
|
||||
|
||||
event.on("installation.update-available", (evt) => {
|
||||
const updater = props.updater
|
||||
const restart = client.restart
|
||||
if (!updater || !restart) return
|
||||
const version = evt.data.version
|
||||
if (updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
event.on("tui.session.select", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
route.navigate({
|
||||
|
||||
@@ -8,22 +8,32 @@ import { useDialog } from "../ui/dialog"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "ignore" }
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
|
||||
export function DialogUpdate(props: {
|
||||
dialogKey: string
|
||||
version: string
|
||||
install: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
dialog.clear()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const beginInstall = () => {
|
||||
@@ -34,16 +44,16 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "ignore") return dialog.clear()
|
||||
if (current.active === "skip") return close()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "ignore") => {
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
@@ -60,7 +70,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => (state().type === "failed" ? dialog.clear() : run()),
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
@@ -81,9 +91,9 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Update
|
||||
Update available
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner>Installing OpenCode {props.version}…</Spinner>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner>Restarting the background service…</Spinner>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
@@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
@@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["ignore", "update"] as const}>
|
||||
<For each={["skip", "update"] as const}>
|
||||
{(action) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "ignore") return dialog.clear()
|
||||
if (action === "skip") return close()
|
||||
beginInstall()
|
||||
}}
|
||||
>
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Ignore"}
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import {
|
||||
batch,
|
||||
createComponent,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useSessionTerminals } from "../context/session-terminals"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { useSessionPanel } from "../context/session-panel"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Session } from "../routes/session"
|
||||
import { Sidebar } from "../routes/session/sidebar"
|
||||
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { createPaneResize } from "../ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../ui/pane-resize-handle"
|
||||
import { useToast } from "../ui/toast"
|
||||
@@ -23,38 +35,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const toast = useToast()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const panel = useSessionPanel()
|
||||
const elevated = useTheme("elevated")
|
||||
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
|
||||
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
|
||||
const terminalResize = createPaneResize({
|
||||
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
|
||||
defaultValue: defaultTerminalWidth,
|
||||
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
|
||||
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
|
||||
initial: {},
|
||||
})
|
||||
const paneResize = createPaneResize({
|
||||
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
|
||||
defaultValue: defaultPaneWidth,
|
||||
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
|
||||
fromMouse: (event) => dimensions().width - event.x - 1,
|
||||
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
|
||||
onCommit: (width) => {
|
||||
void updateLayout((draft) => {
|
||||
draft.terminalWidth = width
|
||||
draft.paneWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let resizeRelease = false
|
||||
const finishTerminalResize = (event: MouseEvent) => {
|
||||
if (terminalResize.resizing()) {
|
||||
const finishPaneResize = (event: MouseEvent) => {
|
||||
if (paneResize.resizing()) {
|
||||
// A captured drag-end can be followed by mouse-up on the focus overlay.
|
||||
resizeRelease = true
|
||||
queueMicrotask(() => {
|
||||
resizeRelease = false
|
||||
})
|
||||
}
|
||||
terminalResize.onMouseUp(event)
|
||||
paneResize.onMouseUp(event)
|
||||
}
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [panelFocused, setPanelFocused] = createSignal(false)
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
let sessionScroll: ScrollBoxRenderable | undefined
|
||||
let focusPanel: (() => void) | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
@@ -65,22 +83,43 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const value = session()
|
||||
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
|
||||
}
|
||||
const activePanel = createMemo(() => {
|
||||
const current = panel.current()
|
||||
if (current?.sessionID === props.sessionID) return current
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
if (!id) return
|
||||
setSidebarOpen(false)
|
||||
if (activePanel()) panel.close()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const splitAvailable = createMemo(() => dimensions().width > 80)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
|
||||
onCleanup(() => panel.setAvailable(props.sessionID, false))
|
||||
createEffect(() => {
|
||||
const current = activePanel()
|
||||
if (!current || splitAvailable()) return
|
||||
panel.close()
|
||||
current.onUnavailable?.()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!activePanel()) return
|
||||
setSidebarOpen(false)
|
||||
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (data.session.get(props.sessionID)?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
|
||||
})
|
||||
const rightPane = createMemo(() => {
|
||||
if (activePanel()) return "panel"
|
||||
if (sidebarOpen() && sidebarVisible()) return "sidebar"
|
||||
if (selectedTerminal()) return "terminal"
|
||||
if (sidebarVisible()) return "sidebar"
|
||||
@@ -94,21 +133,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && activePanel()) panel.close()
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const focusRightPane = () => {
|
||||
if (activePanel()) {
|
||||
focusPanel?.()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => activePanel()?.id,
|
||||
(id) => {
|
||||
if (!id) {
|
||||
setPanelFocused(false)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (activePanel()?.id !== id) return
|
||||
focusPanel?.()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
@@ -117,10 +179,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus terminal pane",
|
||||
run: () => {
|
||||
focusTerminal?.()
|
||||
},
|
||||
title: "Focus right pane",
|
||||
run: focusRightPane,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -132,9 +192,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={terminalResize.onMouseDrag}
|
||||
onMouseDragEnd={finishTerminalResize}
|
||||
onMouseUp={finishTerminalResize}
|
||||
onMouseDrag={paneResize.onMouseDrag}
|
||||
onMouseDragEnd={finishPaneResize}
|
||||
onMouseUp={finishPaneResize}
|
||||
>
|
||||
<box
|
||||
flexGrow={1}
|
||||
@@ -149,13 +209,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
promptMuted={terminalFocused() || panelFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<Show when={terminalFocused() || panelFocused()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
@@ -174,37 +234,61 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
}}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={() => {
|
||||
if (terminalResize.resizing() || resizeRelease) return
|
||||
if (paneResize.resizing() || resizeRelease) return
|
||||
focusSession()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
flexShrink={0}
|
||||
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={terminalResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
<Show
|
||||
keyed
|
||||
when={activePanel()}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={paneResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) =>
|
||||
createComponent(item.render, {
|
||||
get width() {
|
||||
return paneResize.size()
|
||||
},
|
||||
get resizing() {
|
||||
return paneResize.resizing()
|
||||
},
|
||||
get focused() {
|
||||
return panelFocused()
|
||||
},
|
||||
onFocusChange: setPanelFocused,
|
||||
onFocusRequest: (value) => (focusPanel = value),
|
||||
close: panel.close,
|
||||
})
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
@@ -212,12 +296,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
|
||||
<PaneResizeHandle
|
||||
resize={terminalResize}
|
||||
left={availableWidth() - terminalResize.size() - 1}
|
||||
highlight="right"
|
||||
/>
|
||||
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
|
||||
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { coast, intensityAt } from "./tab-pulse"
|
||||
|
||||
type ShimmerTextOptions = TextOptions & {
|
||||
shimmer: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 1200
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
class ShimmerTextRenderable extends TextRenderable {
|
||||
private _shimmer = RGBA.defaultForeground()
|
||||
private elapsed = 0
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
|
||||
super(ctx, options)
|
||||
this.matrix[3] = this._shimmer.r
|
||||
this.matrix[7] = this._shimmer.g
|
||||
this.matrix[11] = this._shimmer.b
|
||||
this.matrix[15] = 1
|
||||
if (options.shimmer) this.shimmer = options.shimmer
|
||||
this.live = true
|
||||
}
|
||||
|
||||
set shimmer(value: RGBA) {
|
||||
if (value.equals(this._shimmer)) return
|
||||
this._shimmer = value
|
||||
this.matrix[3] = value.r
|
||||
this.matrix[7] = value.g
|
||||
this.matrix[11] = value.b
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
this.elapsed = (this.elapsed + deltaTime) % DURATION
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
let strength = 0
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ shimmer_text: ShimmerTextRenderable })
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
shimmer_text: typeof ShimmerTextRenderable
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
|
||||
|
||||
export function ShimmerText(props: Props) {
|
||||
const [local, text] = splitProps(props, ["shimmer"])
|
||||
return <shimmer_text {...text} shimmer={local.shimmer} />
|
||||
}
|
||||
@@ -1,30 +1,48 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useConfig } from "../config"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
import { SPINNER_FRAMES } from "./spinner-frames"
|
||||
import { ShimmerText } from "./shimmer-text"
|
||||
|
||||
export { SPINNER_FRAMES } from "./spinner-frames"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.text.subdued
|
||||
const [frame, setFrame] = createSignal(0)
|
||||
createEffect(() => {
|
||||
if (!(config.animations ?? true) || !props.shimmer) return
|
||||
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={config.animations ?? true}
|
||||
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={props.shimmer}
|
||||
fallback={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(shimmer) => (
|
||||
<ShimmerText fg={color()} shimmer={shimmer()}>
|
||||
{SPINNER_FRAMES[frame()]} {props.children}
|
||||
</ShimmerText>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export const Definitions = {
|
||||
"diff.single_patch": keybind("s", "Toggle single patch view"),
|
||||
"diff.switch_source": keybind("d", "Switch diff viewer source"),
|
||||
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
|
||||
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
|
||||
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
|
||||
|
||||
@@ -93,7 +94,7 @@ export const Definitions = {
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
|
||||
export type SessionPanelRenderProps = {
|
||||
readonly width: number
|
||||
readonly resizing: boolean
|
||||
readonly focused: boolean
|
||||
readonly onFocusChange: (focused: boolean) => void
|
||||
readonly onFocusRequest: (focus: (() => void) | undefined) => void
|
||||
readonly close: () => void
|
||||
}
|
||||
|
||||
type Panel = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly render: (props: SessionPanelRenderProps) => JSX.Element
|
||||
readonly onUnavailable?: () => void
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
readonly current: () => Panel | undefined
|
||||
readonly open: (panel: Panel) => void
|
||||
readonly close: () => void
|
||||
readonly available: (sessionID: string) => boolean
|
||||
readonly setAvailable: (sessionID: string, available: boolean) => void
|
||||
}>()
|
||||
|
||||
export function SessionPanelProvider(props: ParentProps) {
|
||||
const [current, setCurrent] = createSignal<Panel>()
|
||||
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
|
||||
return (
|
||||
<Context.Provider
|
||||
value={{
|
||||
current,
|
||||
open: setCurrent,
|
||||
close: () => setCurrent(),
|
||||
available: (sessionID) => availableSessionID() === sessionID,
|
||||
setAvailable: (sessionID, available) =>
|
||||
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSessionPanel() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useOptionalSessionPanel() {
|
||||
return useContext(Context)
|
||||
}
|
||||
@@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
|
||||
const plugins = usePlugin()
|
||||
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
|
||||
const failed = createMemo(
|
||||
() =>
|
||||
plugins.list().filter((item) => item.status === "failed").length +
|
||||
plugins.server().filter((item) => item.state.status === "failed").length,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
MouseButton,
|
||||
TextAttributes,
|
||||
type BoxRenderable,
|
||||
@@ -22,7 +23,8 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { useConfig } from "../../config"
|
||||
import { locationKey } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useOptionalSessionPanel } from "../../context/session-panel"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -75,9 +77,55 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
return "Uncommitted"
|
||||
}
|
||||
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
|
||||
|
||||
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
|
||||
panel.open({
|
||||
id: ROUTE,
|
||||
sessionID,
|
||||
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
|
||||
render: (input) => (
|
||||
<DiffViewer
|
||||
context={context}
|
||||
sessionID={sessionID}
|
||||
width={input.width}
|
||||
embedded
|
||||
focused={input.focused}
|
||||
onFocusChange={input.onFocusChange}
|
||||
onFocusRequest={input.onFocusRequest}
|
||||
onClose={input.close}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
|
||||
panel.close()
|
||||
context.ui.router.navigate({
|
||||
type: "plugin",
|
||||
name: ROUTE,
|
||||
data: {
|
||||
sessionID,
|
||||
returnRoute: { type: "session", sessionID },
|
||||
...(split ? { split: true } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function DiffViewer(props: {
|
||||
context: Plugin.Context
|
||||
sessionID?: string
|
||||
width?: number
|
||||
embedded?: boolean
|
||||
focused?: boolean
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const config = useConfig()
|
||||
const panel = useOptionalSessionPanel()
|
||||
const [memory, updateMemory] = props.context.storage.memory<{
|
||||
source?: DiffMode
|
||||
bases: Record<string, string>
|
||||
@@ -89,13 +137,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
mode?: DiffMode
|
||||
sessionID?: string
|
||||
returnRoute?: Route
|
||||
split?: boolean
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
|
||||
const location = createMemo(
|
||||
() => {
|
||||
const sessionID = params()?.sessionID
|
||||
const sessionID = props.sessionID ?? params()?.sessionID
|
||||
return sessionID
|
||||
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
|
||||
: props.context.data.location.default()
|
||||
@@ -157,52 +206,103 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!base) return "Base not reported"
|
||||
return `vs ${base.name}`
|
||||
}
|
||||
const sessionID = () => props.sessionID ?? params()?.sessionID
|
||||
const canToggleFullscreen = () =>
|
||||
panel !== undefined &&
|
||||
sessionID() !== undefined &&
|
||||
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
|
||||
const toggleFullscreen = () => {
|
||||
const id = sessionID()
|
||||
if (!panel || !id) return
|
||||
if (props.embedded) {
|
||||
openDiffFullscreen(props.context, panel, id, true)
|
||||
return
|
||||
}
|
||||
openDiffPanel(props.context, panel, id)
|
||||
props.context.ui.router.navigate({ type: "session", sessionID: id })
|
||||
}
|
||||
let panelNode: BoxRenderable | undefined
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => {
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
const content = () => (
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
|
||||
width={props.width}
|
||||
fileTree={!props.embedded}
|
||||
elevated={props.embedded}
|
||||
focused={props.embedded ? props.focused === true : true}
|
||||
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => {
|
||||
if (props.onClose) return props.onClose()
|
||||
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
|
||||
}}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (props.embedded)
|
||||
return (
|
||||
<box
|
||||
ref={(node: BoxRenderable) => {
|
||||
panelNode = node
|
||||
props.onFocusRequest?.(() => node.focus())
|
||||
}}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
onMouseDown={() => panelNode?.focus()}
|
||||
>
|
||||
{content()}
|
||||
</box>
|
||||
)
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
{content()}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -266,28 +366,36 @@ export function DiffViewerContent(props: {
|
||||
navigation?: "tree" | "list"
|
||||
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
|
||||
preferences?: DiffPreferences
|
||||
width?: number
|
||||
fileTree?: boolean
|
||||
elevated?: boolean
|
||||
focused?: boolean
|
||||
onPreferencesChange?: (value: DiffPreferences) => void
|
||||
onClose: () => void
|
||||
onSwitchSource: (mode: DiffMode) => void
|
||||
onChooseBase?: () => void
|
||||
onToggleFullscreen?: () => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const theme = useThemes().current
|
||||
const currentSyntax = useThemes().currentSyntax
|
||||
const themes = useThemes()
|
||||
const elevated = useTheme("elevated")
|
||||
const theme = props.elevated ? elevated : themes.current
|
||||
const currentSyntax = themes.currentSyntax
|
||||
const files = () => props.files
|
||||
const width = () => props.width ?? dimensions().width
|
||||
const mode = () => props.mode
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
|
||||
const showFileTree = createMemo(
|
||||
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
)
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
|
||||
const fileTreeWidth = createMemo(() =>
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
|
||||
)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
|
||||
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
|
||||
const view = createMemo(() =>
|
||||
@@ -301,6 +409,7 @@ export function DiffViewerContent(props: {
|
||||
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
|
||||
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
||||
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
|
||||
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
|
||||
@@ -683,6 +792,13 @@ export function DiffViewerContent(props: {
|
||||
props.onPreferencesChange?.({ view: next })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.toggle_fullscreen",
|
||||
title: "Toggle diff viewer full screen",
|
||||
group: "VCS",
|
||||
enabled: () => props.onToggleFullscreen !== undefined,
|
||||
run: () => props.onToggleFullscreen?.(),
|
||||
},
|
||||
{
|
||||
id: "diff.help",
|
||||
title: "Show more diff viewer shortcuts",
|
||||
@@ -748,7 +864,13 @@ export function DiffViewerContent(props: {
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
|
||||
dialog.show(() => (
|
||||
<DiffViewerHelpDialog
|
||||
context={props.context}
|
||||
single={singlePatch()}
|
||||
fullscreen={props.onToggleFullscreen !== undefined}
|
||||
/>
|
||||
))
|
||||
dialog.set({ size: "medium", centered: true })
|
||||
}
|
||||
|
||||
@@ -777,6 +899,7 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
enabled: () => props.focused !== false,
|
||||
commands,
|
||||
}))
|
||||
|
||||
@@ -874,7 +997,13 @@ export function DiffViewerContent(props: {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
paddingLeft={props.elevated ? 1 : 2}
|
||||
paddingRight={props.elevated ? 1 : 2}
|
||||
>
|
||||
<box
|
||||
id="diff-patch-top-edge"
|
||||
ref={(edge: BoxRenderable) => {
|
||||
@@ -953,7 +1082,7 @@ export function DiffViewerContent(props: {
|
||||
zIndex={1}
|
||||
backgroundColor={background()}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingRight={props.elevated ? 0 : 1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
@@ -1056,7 +1185,45 @@ export function DiffViewerContent(props: {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={!showFileTree()}>
|
||||
<Show when={props.elevated}>
|
||||
<box height={1} flexShrink={0} />
|
||||
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
|
||||
<Show
|
||||
when={props.focused}
|
||||
fallback={
|
||||
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
|
||||
{" focus diff"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
|
||||
{" focus session "}
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
|
||||
{" full screen "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
|
||||
</span>
|
||||
{" scroll "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
|
||||
</span>
|
||||
{" files "}
|
||||
<span style={{ fg: theme.text.default }}>
|
||||
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
|
||||
</span>
|
||||
{" hunks "}
|
||||
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
|
||||
{" close "}
|
||||
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
|
||||
{" see all"}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} />
|
||||
</Show>
|
||||
<Show when={!showFileTree() && !props.elevated}>
|
||||
<box position="absolute" top={0} right={0} width={1} height={1}>
|
||||
<HelpShortcut compact />
|
||||
</box>
|
||||
@@ -1146,7 +1313,7 @@ function DiffFileMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const shortcut =
|
||||
@@ -1186,6 +1353,9 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
|
||||
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
|
||||
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
|
||||
...(props.fullscreen
|
||||
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
|
||||
: []),
|
||||
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
|
||||
],
|
||||
},
|
||||
@@ -1243,6 +1413,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const panel = useOptionalSessionPanel()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -1254,6 +1425,15 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
palette: true,
|
||||
run() {
|
||||
const route = props.context.ui.router.current()
|
||||
if (route.type === "session" && panel?.available(route.sessionID)) {
|
||||
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
|
||||
panel.close()
|
||||
} else {
|
||||
openDiffPanel(props.context, panel, route.sessionID)
|
||||
}
|
||||
props.context.ui.dialog.clear()
|
||||
return
|
||||
}
|
||||
const returnRoute: Route =
|
||||
route.type === "home"
|
||||
? { type: "home" }
|
||||
|
||||
@@ -60,12 +60,16 @@ export default Plugin.define({
|
||||
context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)),
|
||||
context.data.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (terminal.has(sessionID)) return
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(context, sessionID, event.data.error.message, "error")
|
||||
const route = context.ui.router.current()
|
||||
if (route.type === "session" && route.sessionID === sessionID)
|
||||
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
|
||||
ended(sessionID)
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -79,7 +79,9 @@ export function PluginsDialog(props: {
|
||||
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
]
|
||||
})
|
||||
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
|
||||
const visibleEntries = createMemo(() =>
|
||||
entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (visibleEntries().some((entry) => entry.key === focused())) return
|
||||
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
|
||||
@@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
function isLocal(entry: Entry) {
|
||||
if (entry.runtime === "server") return entry.plugin.source.type === "local"
|
||||
const target = entry.target
|
||||
return (
|
||||
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
)
|
||||
return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
}
|
||||
|
||||
function status(entry: Entry) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import type { MarkdownCodeBlockRenderer, MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
@@ -33,6 +33,7 @@ import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot,
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
export interface PackageSource {
|
||||
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
|
||||
@@ -52,6 +53,7 @@ type RegisteredPlugin = {
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly server: () => readonly PluginInfo[]
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slots: {
|
||||
@@ -83,30 +85,21 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function combineMarkdownRenderers(
|
||||
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
|
||||
): MarkdownOptions["renderNode"] {
|
||||
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
|
||||
for (const source of sources) {
|
||||
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
|
||||
}
|
||||
if (renderers.size === 0) return undefined
|
||||
return createMarkdownCodeBlockRenderer(renderers)
|
||||
}
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
}
|
||||
>
|
||||
>([])
|
||||
const [serverPlugins, setServerPlugins] = createSignal<readonly PluginInfo[]>([])
|
||||
const serverTuiPlugins = createMemo(() =>
|
||||
serverPlugins().filter(
|
||||
(plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
)
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -125,12 +118,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMemo(() =>
|
||||
combineMarkdownRenderers(
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
registration.active ? [registration.markdown] : [],
|
||||
),
|
||||
),
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
const clearContributions = (id: string) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
@@ -271,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
install: true,
|
||||
optional: true,
|
||||
})),
|
||||
...serverPlugins().map((plugin) => ({
|
||||
...serverTuiPlugins().map((plugin) => ({
|
||||
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
|
||||
install: false,
|
||||
optional: true,
|
||||
@@ -487,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -500,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
} =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((response) => {
|
||||
const failed = response.data.filter(
|
||||
(plugin) =>
|
||||
plugin.state.status === "failed" &&
|
||||
!serverPlugins().some(
|
||||
(previous) =>
|
||||
serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state),
|
||||
),
|
||||
)
|
||||
setServerPlugins(response.data)
|
||||
const first = failed[0]
|
||||
if (!first) return
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`,
|
||||
message:
|
||||
(failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.",
|
||||
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
@@ -552,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
server: serverPlugins,
|
||||
registered: () =>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({
|
||||
id,
|
||||
@@ -572,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
)
|
||||
}
|
||||
|
||||
function serverPluginName(plugin: PluginInfo) {
|
||||
return (
|
||||
plugin.id ??
|
||||
(plugin.source.type === "package"
|
||||
? plugin.source.target
|
||||
: plugin.source.type === "local"
|
||||
? plugin.source.path
|
||||
: plugin.source.type)
|
||||
)
|
||||
}
|
||||
|
||||
async function disposeAll(cleanups: Dispose[]) {
|
||||
const failures: unknown[] = []
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { isShallowEqual } from "remeda"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
export function createMarkdownRenderer(
|
||||
sources: () => ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
|
||||
) {
|
||||
// Changing renderNode makes OpenTUI destroy and rebuild every Markdown block.
|
||||
// Only invalidate it when the effective last-wins language handlers change.
|
||||
const renderers = createMemo(
|
||||
() => Object.fromEntries(sources().flatMap((source) => Object.entries(source))),
|
||||
undefined,
|
||||
{ equals: isShallowEqual },
|
||||
)
|
||||
return createMemo(() =>
|
||||
Object.keys(renderers()).length === 0 ? undefined : createMarkdownCodeBlockRenderer(renderers()),
|
||||
)
|
||||
}
|
||||
@@ -1507,7 +1507,7 @@ export function Session(props: {
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
disabled={props.promptMuted}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
|
||||
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function clampTerminalPaneWidth(width: number, total: number) {
|
||||
export function clampSessionPaneWidth(width: number, total: number) {
|
||||
const half = Math.max(1, Math.floor(total / 2))
|
||||
// Preserve the equal split when there is not enough room for both pane minima.
|
||||
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createEventStream, createFetch, directory, json, type FetchHandler } fr
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import type { TuiInput } from "../src/app"
|
||||
import type { Config } from "../src/config"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
@@ -1368,6 +1369,168 @@ test.each(["manual", "select"] as const)(
|
||||
},
|
||||
)
|
||||
|
||||
test.each([100, 44])(
|
||||
"execution failure keeps the empty session composer and draft usable at width %s",
|
||||
async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const session = {
|
||||
id: "ses_failure",
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
title: "Failure fixture",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
args: { sessionID: session.id },
|
||||
config: { animations: false, tabs: { enabled: false } },
|
||||
fetch: (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: [], cursor: {} })
|
||||
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
||||
return json({ data: [] })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("u", { ctrl: true })
|
||||
await setup.mockInput.typeText("Keep this draft")
|
||||
await setup.waitForFrame((frame) => frame.includes("Keep this draft"))
|
||||
setup.events.emit({
|
||||
id: "evt_execution_failed",
|
||||
created: 2,
|
||||
type: "session.execution.failed",
|
||||
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: session.id,
|
||||
error: { type: "unknown", message: 'Plugin "broken-skills" failed during skill.transform.' },
|
||||
},
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes("Session failed"))
|
||||
expect(setup.captureCharFrame()).toContain("broken-skills")
|
||||
expect(setup.captureCharFrame()).toContain("skill.transform")
|
||||
expect(setup.captureCharFrame()).toContain("Keep this draft")
|
||||
expect(setup.captureCharFrame()).not.toContain("Select directory")
|
||||
await setup.mockInput.typeText(" intact")
|
||||
await setup.waitForFrame((frame) => frame.includes("Keep this draft intact"))
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
[100, true],
|
||||
[44, true],
|
||||
[100, false],
|
||||
[44, false],
|
||||
] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => {
|
||||
await using state = await tmpdir()
|
||||
const failure: PluginInfo["state"] = {
|
||||
status: "failed",
|
||||
error: "Plugin disabled after command.transform failed. Check server logs for details.",
|
||||
ref: "err_fixture",
|
||||
}
|
||||
let inventory: PluginInfo[] = [
|
||||
{
|
||||
id: "broken",
|
||||
source: { type: "builtin" },
|
||||
features: { server: true },
|
||||
state: initial ? failure : { status: "active" },
|
||||
},
|
||||
{ id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } },
|
||||
]
|
||||
let requests = 0
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
fetch: (url) => {
|
||||
if (url.pathname !== "/api/plugin") return undefined
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: inventory,
|
||||
})
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
if (!initial) {
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed")
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} })
|
||||
}
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("/plugins")
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const row = lines.findIndex((line) => line.includes("Open plugins"))
|
||||
expect(row).toBeGreaterThanOrEqual(0)
|
||||
const line = lines[row]
|
||||
if (!line) throw new Error("Open plugins action is missing")
|
||||
await setup.mockMouse.click(line.indexOf("Open plugins"), row)
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("broken")
|
||||
expect(setup.captureCharFrame()).not.toContain("healthy")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed"))
|
||||
expect(setup.captureCharFrame()).toContain("Plugin disabled")
|
||||
expect(setup.captureCharFrame()).toContain("transform failed")
|
||||
expect(setup.captureCharFrame()).toContain("err_fixture")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("ctrl+a"))
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const seen = requests
|
||||
setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} })
|
||||
setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} })
|
||||
await setup.waitFor(() => requests >= seen + 2)
|
||||
await setup.flush()
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed:")
|
||||
|
||||
inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } }))
|
||||
setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => !frame.includes("1 plugin failed"))
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
})
|
||||
|
||||
test("server plugin failures share one notice and use source names before an ID is known", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createAppFixture({
|
||||
state: state.path,
|
||||
fetch: (url) =>
|
||||
url.pathname === "/api/plugin"
|
||||
? json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: [
|
||||
{
|
||||
source: { type: "package", target: "missing-package" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Package missing" },
|
||||
},
|
||||
{
|
||||
source: { type: "local", path: "/fixture/broken.ts" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Invalid plugin" },
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("2 plugins failed"))
|
||||
expect(setup.captureCharFrame()).toContain("missing-package")
|
||||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "../../../../src/feature-plugins/system/notifications"
|
||||
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
|
||||
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
|
||||
import type { AttentionNotifyOptions, Context, Route, ToastOptions } from "@opencode-ai/plugin/tui/context"
|
||||
|
||||
type Session = { id: string; title: string; parentID?: string }
|
||||
|
||||
async function setup() {
|
||||
async function setup(route: Route = { type: "session", sessionID: "session" }) {
|
||||
const notifications: AttentionNotifyOptions[] = []
|
||||
const toasts: ToastOptions[] = []
|
||||
const handlers = new Map<OpenCodeEvent["type"], ((event: OpenCodeEvent) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
@@ -21,6 +22,10 @@ async function setup() {
|
||||
}
|
||||
|
||||
await Notifications.setup({
|
||||
ui: {
|
||||
router: { current: () => route },
|
||||
toast: { show: (toast: ToastOptions) => toasts.push(toast) },
|
||||
},
|
||||
attention: {
|
||||
async notify(input: AttentionNotifyOptions) {
|
||||
notifications.push(input)
|
||||
@@ -52,6 +57,7 @@ async function setup() {
|
||||
|
||||
return {
|
||||
notifications,
|
||||
toasts,
|
||||
emit(event: OpenCodeEvent) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
@@ -140,6 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("shows execution failures in the viewed session without needing an assistant message", async () => {
|
||||
const harness = await setup()
|
||||
harness.emit(executionStarted("started"))
|
||||
harness.emit(executionFailed("failed"))
|
||||
harness.emit(executionFailed("duplicate"))
|
||||
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
|
||||
harness.emit(executionStarted("retry"))
|
||||
harness.emit(executionFailed("failed-again"))
|
||||
expect(harness.toasts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
|
||||
"keeps other sessions' failures out of the current composer (%j)",
|
||||
async (route) => {
|
||||
const harness = await setup(route)
|
||||
harness.emit(executionFailed("failed"))
|
||||
expect(harness.toasts).toEqual([])
|
||||
expect(harness.notifications).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
|
||||
test("notifies for form and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
|
||||
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
|
||||
import { createDialogApi } from "../../../src/plugin/api"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { SessionPanelProvider } from "../../../src/context/session-panel"
|
||||
import { createSignal, Show } from "solid-js"
|
||||
import { diffImageFixture } from "../../fixture/diff-image"
|
||||
|
||||
@@ -69,6 +70,43 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
|
||||
const narrow = await renderDiffViewer([], {
|
||||
width: 80,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = narrow.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
|
||||
} finally {
|
||||
narrow.app.renderer.destroy()
|
||||
}
|
||||
|
||||
const eligible = await renderDiffViewer([], {
|
||||
width: 160,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = eligible.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
|
||||
command?.run()
|
||||
await eligible.app.flush()
|
||||
expect(eligible.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
eligible.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the diff viewer without exiting the application", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
|
||||
@@ -1972,7 +2010,9 @@ async function renderDiffViewer(
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
<SessionPanelProvider>
|
||||
<Content />
|
||||
</SessionPanelProvider>
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
||||
"diff.next_hunk": "]",
|
||||
"diff.previous_hunk": "[",
|
||||
"diff.mark_reviewed": "m",
|
||||
"diff.toggle_fullscreen": "f",
|
||||
"diff.help": "?,shift+?,shift+/",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
CodeRenderable,
|
||||
MarkdownRenderable,
|
||||
SyntaxStyle,
|
||||
TextRenderable,
|
||||
type MarkdownCodeBlockRenderer,
|
||||
} from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createMarkdownRenderer } from "../src/plugin/markdown"
|
||||
|
||||
test("unrelated plugin toggles preserve mounted Markdown blocks", async () => {
|
||||
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
|
||||
const handler: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "Custom fence", height: 1 })
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: handler },
|
||||
{},
|
||||
])
|
||||
await render(() => {
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
return (
|
||||
<markdown
|
||||
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
|
||||
renderNode={renderNode()}
|
||||
content={"A plain paragraph.\n\n```example\nFence content\n```"}
|
||||
streaming={false}
|
||||
internalBlockMode="top-level"
|
||||
/>
|
||||
)
|
||||
}, output.renderer)
|
||||
try {
|
||||
output.renderer.start()
|
||||
await output.waitForFrame((frame) => frame.includes("Custom fence"))
|
||||
const markdown = output.renderer.root.getChildren()[0]
|
||||
if (!(markdown instanceof MarkdownRenderable)) throw new Error("Expected Markdown")
|
||||
const initial = markdown.getChildren()
|
||||
expect(initial).toHaveLength(2)
|
||||
expect(output.captureCharFrame()).toContain("Custom fence")
|
||||
|
||||
for (const active of [false, true, false, true]) {
|
||||
setSources([{ example: handler }, ...(active ? [{}] : [])])
|
||||
await output.renderOnce()
|
||||
expect(markdown.getChildren()[0] === initial[0]).toBe(true)
|
||||
expect(markdown.getChildren()[1] === initial[1]).toBe(true)
|
||||
expect(initial.every((block) => !block.isDestroyed)).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("effective mappings preserve identity through reordered and shadowed contributions", () => {
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const first: MarkdownCodeBlockRenderer = () => undefined
|
||||
const second: MarkdownCodeBlockRenderer = () => undefined
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: first },
|
||||
{ example: second, other: first },
|
||||
])
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
const initial = renderNode()
|
||||
|
||||
setSources([{ other: first, example: second }])
|
||||
expect(renderNode()).toBe(initial)
|
||||
setSources([{ example: first }, { other: first, example: second }])
|
||||
expect(renderNode()).toBe(initial)
|
||||
|
||||
setSources([{ example: first, other: first }])
|
||||
expect(renderNode()).not.toBe(initial)
|
||||
setSources([])
|
||||
expect(renderNode()).toBeUndefined()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("changing and removing a Markdown handler refreshes existing messages", async () => {
|
||||
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
|
||||
const first: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "First renderer", height: 1 })
|
||||
const second: MarkdownCodeBlockRenderer = () =>
|
||||
new TextRenderable(output.renderer, { content: "Second renderer", height: 1 })
|
||||
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
|
||||
{ example: first },
|
||||
])
|
||||
await render(() => {
|
||||
const renderNode = createMarkdownRenderer(sources)
|
||||
return (
|
||||
<markdown
|
||||
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
|
||||
renderNode={renderNode()}
|
||||
content={"```example\nFence content\n```"}
|
||||
streaming={false}
|
||||
internalBlockMode="top-level"
|
||||
/>
|
||||
)
|
||||
}, output.renderer)
|
||||
try {
|
||||
output.renderer.start()
|
||||
await output.waitForFrame((frame) => frame.includes("First renderer"))
|
||||
|
||||
setSources([{ example: first }, { example: second }])
|
||||
await output.waitForFrame((frame) => frame.includes("Second renderer"))
|
||||
|
||||
setSources([{ example: first }])
|
||||
await output.waitForFrame((frame) => frame.includes("First renderer"))
|
||||
|
||||
setSources([])
|
||||
await output.waitForFrame((frame) => frame.includes("Fence content"))
|
||||
expect(output.renderer.root.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(CodeRenderable)
|
||||
|
||||
setSources([{ example: second }])
|
||||
await output.waitForFrame((frame) => frame.includes("Second renderer"))
|
||||
} finally {
|
||||
output.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
@@ -13899,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -8890,7 +8890,8 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "An absolute path or a path relative to the requested location. Defaults to the location directory."
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
@@ -8941,7 +8942,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List direct children of one directory relative to the requested location.",
|
||||
"description": "List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
"summary": "List directory"
|
||||
}
|
||||
},
|
||||
@@ -13777,8 +13778,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"subtask": {
|
||||
"subagent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean",
|
||||
"description": "Deprecated alias for subagent."
|
||||
}
|
||||
},
|
||||
"required": ["template"],
|
||||
@@ -13899,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
"enum": ["disable", "notify"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
|
||||
@@ -55,15 +55,16 @@ Add commands under the `commands` key in any OpenCode JSON or JSONC
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| ------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
|
||||
| `description` | No | Text shown with the command in command listings and discovery. |
|
||||
| `agent` | No | Agent activated before the prompt runs. |
|
||||
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
|
||||
| `subtask` | No | Accepted as a boolean, but currently has no execution effect in V2. |
|
||||
| Field | Required | Behavior |
|
||||
| ------------- | --------- | --------------------------------------------------------------------------------- |
|
||||
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
|
||||
| `description` | No | Text shown with the command in command listings and discovery. |
|
||||
| `agent` | No | Agent that runs the command. |
|
||||
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
|
||||
| `subagent` | No | Run in a background child session, or use `false` to stay in the current session. |
|
||||
| `subtask` | No | Deprecated alias for `subagent`. |
|
||||
|
||||
The four optional fields can be used in JSON or YAML frontmatter. Do not put
|
||||
The optional fields can be used in JSON or YAML frontmatter. Do not put
|
||||
`template` in frontmatter because the Markdown body always supplies it.
|
||||
|
||||
## Arguments
|
||||
@@ -128,16 +129,33 @@ automatically attach that file.
|
||||
|
||||
## Agent, model, and execution
|
||||
|
||||
Running a command evaluates its arguments and shell blocks, submits the result
|
||||
as a durable user prompt in the current session, and schedules normal model
|
||||
execution.
|
||||
Commands evaluate their arguments and shell blocks before submitting a durable
|
||||
user prompt. Commands run in the current session unless background delegation
|
||||
is enabled as described below.
|
||||
|
||||
If `agent` is set, it overrides the active agent when the command is invoked
|
||||
For current-session commands, `agent` overrides the active agent when the command is invoked
|
||||
and becomes the session's active agent. If `model` is set, it overrides the
|
||||
model. Otherwise, a model configured on the command's agent takes precedence
|
||||
over the model active at invocation.
|
||||
|
||||
Although `subtask` is accepted in JSON and frontmatter, V2 currently ignores
|
||||
it: commands run in the current session and do not create a child session.
|
||||
Selecting an agent whose mode is `subagent` also does not turn the command into
|
||||
a subtask.
|
||||
### Background subagents
|
||||
|
||||
Set `subagent: true` to run a command in a background child session. The parent
|
||||
keeps its agent and model, stays available for other work, and receives the
|
||||
child's result or failure when it finishes.
|
||||
|
||||
```md title=".opencode/commands/review.md"
|
||||
---
|
||||
description: Review changes in the background
|
||||
agent: general
|
||||
subagent: true
|
||||
---
|
||||
|
||||
Review $ARGUMENTS for bugs and missing tests.
|
||||
```
|
||||
|
||||
- `true` forces child execution, including for an agent with `mode: primary`.
|
||||
- `false` forces execution in the current session.
|
||||
- When omitted, a command targeting an agent with `mode: subagent` runs in the background.
|
||||
- The child uses the command's model override, then the selected agent's model, then the parent's model.
|
||||
- Legacy `subtask` is still accepted in JSON and Markdown. If both fields are present, `subagent` takes precedence.
|
||||
|
||||
@@ -129,15 +129,13 @@ agents.
|
||||
|
||||
### Updates
|
||||
|
||||
Control updates from the global config. Set `update` to `"disable"` to skip
|
||||
updates, `"notify"` to report available updates without installing them, or
|
||||
`"auto"` to automatically install compatible non-major updates.
|
||||
Major updates are reported but never installed automatically.
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
skip them or `"notify"` to show available updates before installing them.
|
||||
Project-level values are ignored.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"update": "auto",
|
||||
"update": "notify",
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ Existing skill files and automatic `.opencode/skills/` discovery do not change.
|
||||
|
||||
### Commands
|
||||
|
||||
Rename the singular `command` map to `commands`. Join a separate model `variant` to the model reference:
|
||||
Rename the singular `command` map to `commands` and `subtask` to `subagent`. Join a separate model `variant` to the model reference:
|
||||
|
||||
```jsonc
|
||||
// V1
|
||||
@@ -281,7 +281,8 @@ Rename the singular `command` map to `commands`. Join a separate model `variant`
|
||||
"review": {
|
||||
"template": "Review the current changes.",
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"variant": "high"
|
||||
"variant": "high",
|
||||
"subtask": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,13 +292,15 @@ Rename the singular `command` map to `commands`. Join a separate model `variant`
|
||||
"commands": {
|
||||
"review": {
|
||||
"template": "Review the current changes.",
|
||||
"model": "anthropic/claude-sonnet-4-5#high"
|
||||
"model": "anthropic/claude-sonnet-4-5#high",
|
||||
"subagent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`template`, `description`, `agent`, and `subtask` keep their names. Existing Markdown command definitions remain supported.
|
||||
`template`, `description`, and `agent` keep their names. Legacy `subtask` remains accepted; delegated commands now run
|
||||
automatically in the background and report their results to the parent session. Existing Markdown command definitions remain supported.
|
||||
See [Commands](/commands).
|
||||
|
||||
### References
|
||||
@@ -406,7 +409,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`.
|
||||
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
@@ -462,16 +466,19 @@ V1 command files may use `command/` or `commands/`. V2 discovers both. The prefe
|
||||
```
|
||||
|
||||
Move files from `command/` to the same relative path under `commands/` to preserve command names. The Markdown body remains
|
||||
the command template, and `description`, `agent`, and `subtask` frontmatter keep the same names. If frontmatter has separate
|
||||
the command template, and `description` and `agent` frontmatter keep the same names. Rename `subtask` to `subagent` to use
|
||||
the native name for background delegation. If frontmatter has separate
|
||||
`model` and `variant` fields, append the variant to the model and remove `variant`:
|
||||
|
||||
```yaml
|
||||
# V1
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
variant: high
|
||||
subtask: true
|
||||
|
||||
# V2
|
||||
model: anthropic/claude-sonnet-4-5#high
|
||||
subagent: true
|
||||
```
|
||||
|
||||
See [Commands](/commands).
|
||||
|
||||
Reference in New Issue
Block a user