Compare commits

..

2 Commits

Author SHA1 Message Date
Brendan Allan 55874ccaeb simplify repalceServerConnection 2026-08-07 19:34:47 +08:00
Brendan Allan b8ed70d6d3 refactor(app): split server management controllers 2026-08-07 17:37:35 +08:00
108 changed files with 1829 additions and 4671 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-1
View File
@@ -395,7 +395,6 @@
"ignore": "7.0.5", "ignore": "7.0.5",
"immer": "11.1.4", "immer": "11.1.4",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
+5 -20
View File
@@ -1,7 +1,6 @@
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import type { RouteDefaultsInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package" import type { ProviderPackage } from "../provider-package"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
@@ -20,8 +19,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string readonly apiKey?: string
readonly baseURL: string readonly baseURL: string
readonly provider?: string readonly provider?: string
readonly http?: RouteDefaultsInput["http"]
readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -34,24 +31,16 @@ export const routes = [OpenAICompatibleChat.route]
export const configure = (input: GenericModelOptions) => { export const configure = (input: GenericModelOptions) => {
const provider = input.provider ?? "openai-compatible" const provider = input.provider ?? "openai-compatible"
const { const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
provider: _,
baseURL,
apiKey: _apiKey,
auth: _auth,
headers,
...rest
} = input
const route = OpenAICompatibleChat.route.with({ const route = OpenAICompatibleChat.route.with({
...rest, ...rest,
provider, provider,
endpoint: { baseURL }, endpoint: { baseURL },
auth: AuthOptions.bearer(input, []).andThen(Auth.headers(headers ?? {})), auth: AuthOptions.bearer(input, []),
}) })
return { return {
id: ProviderID.make(provider), id: ProviderID.make(provider),
model: (modelID: string | ModelID) => model: (modelID: string | ModelID) =>
// oxlint-disable-next-line typescript-eslint/no-unnecessary-type-arguments -- preserves provider-option validation at call sites
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }), route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
configure, configure,
} }
@@ -78,18 +67,14 @@ export const provider = {
configure, configure,
} }
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({ configure({
apiKey: settings.apiKey, apiKey: settings.apiKey,
baseURL: settings.baseURL, baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers }, headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: mergeHttpOptions( http: settings.body === undefined ? undefined : { body: { ...settings.body } },
settings.http === undefined ? undefined : HttpOptions.make(settings.http),
settings.body === undefined ? undefined : new HttpOptions({ body: { ...settings.body } }),
),
limits: settings.limits, limits: settings.limits,
provider: settings.provider, provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID) }).model(modelID)
export const baseten = define(profiles.baseten) export const baseten = define(profiles.baseten)
-16
View File
@@ -111,22 +111,6 @@ describe("provider package entrypoints", () => {
}) })
}) })
test("maps OpenAI-compatible Chat settings onto the executable model", async () => {
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
const selected = OpenAICompatible.model("custom-model", {
apiKey: "fixture",
baseURL: "https://chat.example.test/v1",
provider: "example",
http: { query: { tenant: "one" } },
providerOptions: { openai: { reasoningEffort: "high" } },
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("openai-compatible-chat")
expect(selected.route.defaults.http?.query).toEqual({ tenant: "one" })
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
})
test("maps Anthropic-compatible settings onto the executable model", async () => { test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible") const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", { const selected = AnthropicCompatible.model("compatible-model", {
@@ -4,7 +4,6 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src" import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client" import { compileRequest } from "../../src/route/client"
import { jsonRequestParts } from "../../src/route/transport/http"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -145,48 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
}), }),
) )
it.effect("preserves compatible provider URL, usage, options, and body extensions", () =>
Effect.gen(function* () {
const selected = OpenAICompatible.model("custom-model", {
apiKey: "generated-key",
baseURL: "https://compatible.example/v1",
provider: "custom",
headers: { Authorization: "Bearer configured-key" },
http: {
query: { tenant: "one" },
body: {
user: "user-1",
verbosity: "low",
vendor_extension: { enabled: true },
custom_boolean: false,
},
},
providerOptions: { openai: { reasoningEffort: "high" } },
})
const request = LLM.request({ model: selected, prompt: "Hello" })
const prepared = yield* compileRequest(request)
const parts = yield* jsonRequestParts({
endpoint: selected.route.endpoint,
auth: selected.route.auth,
headers: selected.route.headers,
request: LLMRequest.update(request, { http: selected.route.defaults.http }),
body: prepared.body,
encodeBody: (body) => JSON.stringify(body),
})
expect(parts.url).toBe("https://compatible.example/v1/chat/completions?tenant=one")
expect(parts.headers.authorization).toBe("Bearer configured-key")
expect(parts.jsonBody).toMatchObject({
user: "user-1",
reasoning_effort: "high",
verbosity: "low",
vendor_extension: { enabled: true },
custom_boolean: false,
})
expect(parts.jsonBody).toMatchObject({ stream_options: { include_usage: true } })
}),
)
it.effect("configures the max tokens request field", () => it.effect("configures the max tokens request field", () =>
Effect.gen(function* () { Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route const compatible = OpenAICompatibleChat.route
@@ -6,21 +6,17 @@ import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query" import { Show } from "solid-js"
import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs" import {
type ServerDomainController,
const DEFAULT_USERNAME = "opencode" type ServerFormController,
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps { interface ServerFormProps {
value: string value: string
@@ -39,76 +35,6 @@ interface ServerFormProps {
onBack: () => void onBack: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultUrlActions] = createResource(
async () => {
try {
const key = await platform.getDefaultServer?.()
if (!key) return null
return key
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
const setDefault = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultUrlActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
}
function useServerPreview() {
const checkServerHealth = useCheckServerHealth()
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (
value: string,
username: string,
password: string,
setStatus: (value: boolean | undefined) => void,
) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const http: ServerConnection.HttpBase = { url: normalized }
if (username) http.username = username
if (password) http.password = password
const result = await checkServerHealth(http)
setStatus(result.healthy)
}
return { previewStatus }
}
function ServerForm(props: ServerFormProps) { function ServerForm(props: ServerFormProps) {
const language = useLanguage() const language = useLanguage()
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
@@ -176,385 +102,40 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() { export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close }) const language = useLanguage()
const domain = useServerDomainController({ onSelect: () => dialog.close() })
const form = useServerFormController({ onSelect: () => dialog.close() })
const title = () => {
if (!form.state.open()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
<span>
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
</span>
</div>
)
}
return ( return (
<Dialog title={controller.formTitle()}> <Dialog title={title()}>
<div class="flex flex-1 min-h-0 flex-col px-5"> <div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}> <Show
<ServerConnectionForm controller={controller} /> when={form.state.open()}
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show> </Show>
</div> </div>
</Dialog> </Dialog>
) )
} }
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { export function ServerConnectionList(props: {
const navigate = useNavigate() domain: ServerDomainController
const server = useServer() onAdd: () => void
const tabs = useTabs() onEdit: (server: ServerConnection.Http) => void
const global = useGlobal() }) {
const platform = usePlatform()
const language = useLanguage()
const { defaultKey, canDefault, setDefault } = useDefaultServer()
const { previewStatus } = useServerPreview()
const checkServerHealth = useCheckServerHealth()
const [store, setStore] = createStore({
addServer: {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined as boolean | undefined,
},
editServer: {
id: undefined as string | undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined as boolean | undefined,
},
})
const resetAdd = () => {
setStore("addServer", {
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
showForm: false,
status: undefined,
})
}
const resetEdit = () => {
setStore("editServer", {
id: undefined,
value: "",
name: "",
username: "",
password: "",
error: "",
status: undefined,
})
}
const addMutation = useMutation(() => ({
mutationFn: async (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) {
resetAdd()
return
}
const conn: ServerConnection.Http = {
type: "http",
http: { url: normalized },
}
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
if (store.addServer.password) conn.http.password = store.addServer.password
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("addServer", { error: language.t("dialog.server.add.error") })
return
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
const editMutation = useMutation(() => ({
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
if (input.original.type !== "http") return
const normalized = normalizeServerUrl(input.value)
if (!normalized) {
resetEdit()
return
}
const name = store.editServer.name.trim() || undefined
const username = store.editServer.username || undefined
const password = store.editServer.password || undefined
const existingName = input.original.displayName
if (
normalized === input.original.http.url &&
name === existingName &&
username === input.original.http.username &&
password === input.original.http.password
) {
resetEdit()
return
}
const conn: ServerConnection.Http = {
type: "http",
displayName: name,
http: { url: normalized, username, password },
}
const result = await checkServerHealth(conn.http)
if (!result.healthy) {
setStore("editServer", { error: language.t("dialog.server.add.error") })
return
}
if (normalized === input.original.http.url) {
server.add(conn)
} else {
replaceServer(input.original, conn)
}
resetEdit()
},
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(originalKey)
}
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const settings = useSettings()
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
)
const sortedItems = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
options.onSelect?.()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
return
}
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddNameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { name: value, error: "" })
}
const handleAddUsernameChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { username: value, error: "" })
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
setStore("addServer", { status: next }),
)
}
const handleAddPasswordChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { password: value, error: "" })
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
setStore("addServer", { status: next }),
)
}
const handleEditChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { value, error: "" })
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditNameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { name: value, error: "" })
}
const handleEditUsernameChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { username: value, error: "" })
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
setStore("editServer", { status: next }),
)
}
const handleEditPasswordChange = (value: string) => {
if (editMutation.isPending) return
setStore("editServer", { password: value, error: "" })
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
setStore("editServer", { status: next }),
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
})
const editing = createMemo(() => {
if (!store.editServer.id) return
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
})
const resetForm = () => {
resetAdd()
resetEdit()
}
const startAdd = () => {
resetEdit()
setStore("addServer", {
showForm: true,
url: "",
name: "",
username: DEFAULT_USERNAME,
password: "",
error: "",
status: undefined,
})
}
const startEdit = (conn: ServerConnection.Http) => {
resetAdd()
setStore("editServer", {
id: conn.http.url,
value: conn.http.url,
name: conn.displayName ?? "",
username: conn.http.username ?? "",
password: conn.http.password ?? "",
error: "",
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
})
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
setStore("addServer", { error: "" })
addMutation.mutate(store.addServer.url)
return
}
const original = editing()
if (!original) return
if (editMutation.isPending) return
setStore("editServer", { error: "" })
editMutation.mutate({ original, value: store.editServer.value })
}
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
</div>
)
})
createEffect(() => {
if (!store.editServer.id) return
if (editing()) return
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
return {
defaultKey,
canDefault,
current,
sortedItems,
status: () => global.servers.health,
isFormMode,
isAddMode,
formTitle,
formBusy,
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
select,
setDefault,
startAdd,
startEdit,
resetForm,
submitForm,
canRemove: server.canRemove,
handleRemove,
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
}
}
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
const language = useLanguage() const language = useLanguage()
const settings = useSettings() const settings = useSettings()
@@ -568,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}} }}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems} items={props.domain.collection.items}
key={(x) => x.http.url} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}} }}
divider={true} divider={true}
> >
@@ -580,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-center w-5"> <div class="flex flex-col h-full items-center w-5">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div> </div>
<ServerRow <ServerRow
conn={i} conn={i}
dimmed={props.controller.status()[key]?.healthy === false} dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.controller.status()[key]} status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1" class="flex items-center gap-3 min-w-0 flex-1"
badge={ badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}> <Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -597,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials showCredentials
/> />
<div class="flex items-center justify-center gap-4 pl-4"> <div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}> <Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" /> <Icon name="check" class="h-6" />
</Show> </Show>
@@ -616,27 +202,27 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
if (i.type !== "http") return if (i.type !== "http") return
props.controller.startEdit(i) props.onEdit(i)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}> <DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={props.controller.canRemove(key)}> <Show when={props.domain.connection.canRemove(key)}>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))} onSelect={() => props.domain.connection.remove(key)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -657,7 +243,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary" variant="secondary"
icon="plus-small" icon="plus-small"
size="large" size="large"
onClick={props.controller.startAdd} onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5" class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
> >
{language.t("dialog.server.add.button")} {language.t("dialog.server.add.button")}
@@ -667,38 +253,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
) )
} }
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) { export function ServerConnectionForm(props: { form: ServerFormController }) {
const language = useLanguage() const language = useLanguage()
return ( return (
<div class="flex flex-1 min-h-0 flex-col gap-4"> <div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm <ServerForm
value={props.controller.formValue()} value={props.form.state.value()}
name={props.controller.formName()} name={props.form.state.name()}
username={props.controller.formUsername()} username={props.form.state.username()}
password={props.controller.formPassword()} password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={props.controller.formBusy()} busy={props.form.state.busy()}
error={props.controller.formError()} error={props.form.state.error()}
status={props.controller.formStatus()} status={props.form.state.status()}
onChange={props.controller.handleFormChange()} onChange={props.form.change.value}
onNameChange={props.controller.handleFormNameChange()} onNameChange={props.form.change.name}
onUsernameChange={props.controller.handleFormUsernameChange()} onUsernameChange={props.form.change.username}
onPasswordChange={props.controller.handleFormPasswordChange()} onPasswordChange={props.form.change.password}
onSubmit={props.controller.submitForm} onSubmit={props.form.submit}
onBack={props.controller.resetForm} onBack={props.form.reset}
/> />
<div class="shrink-0 pb-5"> <div class="shrink-0 pb-5">
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
onClick={props.controller.submitForm} onClick={props.form.submit}
disabled={props.controller.formBusy()} disabled={props.form.state.busy()}
class="px-3 py-1.5" class="px-3 py-1.5"
> >
{props.controller.formBusy() {props.form.state.busy()
? language.t("dialog.server.add.checking") ? language.t("dialog.server.add.checking")
: props.controller.isAddMode() : props.form.state.adding()
? language.t("dialog.server.add.button") ? language.t("dialog.server.add.button")
: language.t("common.save")} : language.t("common.save")}
</Button> </Button>
@@ -0,0 +1,323 @@
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { showToast } from "@/utils/toast"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
const DEFAULT_USERNAME = "opencode"
type FormMode = "list" | "add" | "edit"
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer() {
const language = useLanguage()
const platform = usePlatform()
const [defaultKey, defaultKeyActions] = createResource(
async () => {
try {
return (await platform.getDefaultServer?.()) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const set = async (key: ServerConnection.Key | null) => {
try {
await platform.setDefaultServer?.(key)
defaultKeyActions.mutate(key)
} catch (err) {
showRequestError(language, err)
}
}
return {
key: () => defaultKey.latest,
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
set,
}
}
function useServerMutations() {
const server = useServer()
const tabs = useTabs()
return {
add: (connection: ServerConnection.Http) => server.add(connection),
replace: (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add: (connection) => server.add(connection),
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
}),
}
}
export function useServerActionsController() {
const server = useServer()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
const defaults = useDefaultServer()
const remove = async (key: ServerConnection.Key) => {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
} catch (err) {
showRequestError(language, err)
}
}
return { defaults, connection: { canRemove: server.canRemove, remove } }
}
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
}
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const language = useLanguage()
const mutations = useServerMutations()
const checkServerHealth = useCheckServerHealth()
const healthPreview = createServerHealthPreview(checkServerHealth)
const [store, setStore] = createStore({
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined as boolean | undefined,
})
onCleanup(healthPreview.cancel)
const reset = () => {
healthPreview.cancel()
setStore({
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
error: "",
status: undefined,
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
)
const request = useMutation(() => ({
mutationFn: async () => {
const normalized = normalizeServerUrl(store.values.url)
if (!normalized) {
reset()
return
}
const original = store.mode === "edit" ? editing() : undefined
if (store.mode === "edit" && !original) return
const name = store.values.name.trim() || undefined
const username = store.values.username || undefined
const password = store.values.password || undefined
if (
original?.type === "http" &&
normalized === original.http.url &&
name === original.displayName &&
username === original.http.username &&
password === original.http.password
) {
reset()
return
}
const connection: ServerConnection.Http = {
type: "http",
displayName: name,
http: {
url: normalized,
username: store.mode === "add" && !password ? undefined : username,
password,
},
}
const result = await checkServerHealth(connection.http)
if (!result.healthy) {
setStore("error", language.t("dialog.server.add.error"))
return
}
if (original?.type === "http") {
if (normalized === original.http.url) mutations.add(connection)
if (normalized !== original.http.url) mutations.replace(ServerConnection.key(original), connection)
reset()
return
}
reset()
if (options.navigateOnAdd === false) {
mutations.add(connection)
options.onSelect?.()
return
}
mutations.add(connection)
options.onSelect?.()
navigate("/")
},
}))
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
const change = (field: keyof ServerFormValues, value: string) => {
if (request.isPending) return
setStore("values", field, value)
setStore("error", "")
if (field !== "name") preview()
}
const startAdd = () => {
reset()
setStore("mode", "add")
}
const startEdit = (connection: ServerConnection.Http) => {
reset()
setStore({
mode: "edit",
originalUrl: connection.http.url,
values: {
url: connection.http.url,
name: connection.displayName ?? "",
username: connection.http.username ?? "",
password: connection.http.password ?? "",
},
error: "",
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
})
}
const submit = () => {
if (store.mode === "list" || request.isPending) return
setStore("error", "")
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
if (editing()) return
reset()
})
return {
state: {
mode: () => store.mode,
open: () => store.mode !== "list",
adding: () => store.mode === "add",
busy: () => request.isPending,
value: () => store.values.url,
name: () => store.values.name,
username: () => store.values.username,
password: () => store.values.password,
error: () => store.error,
status: () => store.status,
},
change: {
value: (value: string) => change("url", value),
name: (value: string) => change("name", value),
username: (value: string) => change("username", value),
password: (value: string) => change("password", value),
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
}
}
export type ServerFormController = ReturnType<typeof useServerFormController>
@@ -0,0 +1,99 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
describe("createServerHealthPreview", () => {
test("ignores an older response that resolves after the latest response", async () => {
const first = deferred<{ healthy: boolean }>()
const second = deferred<{ healthy: boolean }>()
const requests = [first, second]
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => requests.shift()!.promise)
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
second.resolve({ healthy: true })
await latest
first.resolve({ healthy: false })
await older
expect(status).toEqual([undefined, undefined, true])
})
test("an incomplete value invalidates an in-flight response", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
await preview.preview(values("server"), (value) => status.push(value))
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined, undefined])
})
test("cancellation prevents an in-flight response from updating status", async () => {
const request = deferred<{ healthy: boolean }>()
const status: Array<boolean | undefined> = []
const preview = createServerHealthPreview(() => request.promise)
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
preview.cancel()
request.resolve({ healthy: true })
await pending
expect(status).toEqual([undefined])
})
})
describe("replaceServerConnection", () => {
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
test("moves active selection after adding the replacement and removes the original", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
test("keeps the original when the replacement cannot be added", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
expect(removed).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
url: string
name: string
username: string
password: string
}
export function createServerHealthPreview(
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
) {
let generation = 0
const cancel = () => {
generation += 1
}
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
const current = ++generation
setStatus(undefined)
const normalized = normalizeServerUrl(values.url)
if (!normalized) return
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
return
const http: ServerConnection.HttpBase = { url: normalized }
if (values.username) http.username = values.username
if (values.password) http.password = values.password
const result = await check(http)
if (current !== generation) return
setStatus(result.healthy)
}
return { cancel, preview }
}
export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js" import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server" import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{ export const ServerRowMenu: Component<{
server: ServerConnection.Any server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController> domain: ServerActionsController
onEdit: (server: ServerConnection.Http) => void onEdit: (server: ServerConnection.Http) => void
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
@@ -19,13 +19,13 @@ export const ServerRowMenu: Component<{
<ServerRowMenuView <ServerRowMenuView
server={props.server} server={props.server}
labels={serverMenuLabels(language)} labels={serverMenuLabels(language)}
canDefault={props.controller.canDefault()} canDefault={props.domain.defaults.available()}
isDefault={props.controller.defaultKey() === key} isDefault={props.domain.defaults.key() === key}
canRemove={props.controller.canRemove(key)} canRemove={props.domain.connection.canRemove(key)}
onEdit={props.onEdit} onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)} onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.controller.setDefault(null)} onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.controller.handleRemove(key)} onRemove={() => props.domain.connection.remove(key)}
open={props.open} open={props.open}
onOpenChange={props.onOpenChange} onOpenChange={props.onOpenChange}
/> />
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server" import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerFormController } from "../server/server-management-controller"
import "./settings-v2.css" import "./settings-v2.css"
export const DialogServerV2: Component<{ export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => { }> = (props) => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController({ const form = useServerFormController({
onSelect: () => dialog.close(), onSelect: () => dialog.close(),
navigateOnAdd: false, navigateOnAdd: false,
}) })
const [opened, setOpened] = createSignal(false) const [opened, setOpened] = createSignal(false)
onMount(() => { onMount(() => {
if (props.mode === "add") controller.startAdd() if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) controller.startEdit(props.server) if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true) setOpened(true)
}) })
onCleanup(() => { onCleanup(() => {
controller.resetForm() form.reset()
}) })
createEffect(() => { createEffect(() => {
if (!opened()) return if (!opened()) return
if (controller.isFormMode()) return if (form.state.open()) return
dialog.close() dialog.close()
}) })
const keyDown = (event: KeyboardEvent) => { const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return if (event.key !== "Enter" || event.isComposing) return
event.preventDefault() event.preventDefault()
controller.submitForm() form.submit()
} }
const title = () => const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title") props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => { const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking") if (form.state.busy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button") if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save") return language.t("common.save")
} }
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formValue()} value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()} invalid={!!form.state.error()}
disabled={controller.formBusy()} disabled={form.state.busy()}
autofocus autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)} onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
<Show when={controller.formError()}> <Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span> <span class="settings-v2-server-dialog-error">{form.state.error()}</span>
</Show> </Show>
</div> </div>
<div class="flex w-full min-w-0 flex-col gap-2"> <div class="flex w-full min-w-0 flex-col gap-2">
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formName()} value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")} placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)} onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text" type="text"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formUsername()} value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")} placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)} onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password" type="password"
appearance="large" appearance="large"
class="!w-full self-stretch" class="!w-full self-stretch"
value={controller.formPassword()} value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")} placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()} disabled={form.state.busy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)} onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown} onKeyDown={keyDown}
/> />
</div> </div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div> </div>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}> <ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")} {language.t("common.cancel")}
</ButtonV2> </ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}> <ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()} {submitLabel()}
</ButtonV2> </ButtonV2>
</DialogFooter> </DialogFooter>
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server" import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server" import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2" import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
@@ -19,16 +19,16 @@ import "./settings-v2.css"
export const SettingsServersV2: Component = () => { export const SettingsServersV2: Component = () => {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const controller = useServerManagementController() const domain = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" }) const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter) const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo( const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, () => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
) )
const filtered = createMemo(() => { const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item)) const items = domain.collection.items().filter((item) => !isWslServer(item))
const query = store.filter.trim() const query = store.filter.trim()
if (!query) return items if (!query) return items
return fuzzysort return fuzzysort
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
}) })
const openAdd = () => { const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />) void dialog.push(() => <DialogServerV2 mode="add" />)
} }
const openEdit = (server: ServerConnection.Http) => { const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />) void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
} }
return ( return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
} }
> >
<SettingsListV2> <SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} /> <WslServerSettings domain={domain} servers={wslServers} />
<For each={filtered()}> <For each={filtered()}>
{(item) => { {(item) => {
const key = ServerConnection.key(item) const key = ServerConnection.key(item)
const health = () => controller.status()[key] const health = () => domain.collection.health()[key]
const isDefault = () => controller.defaultKey() === key const isDefault = () => domain.defaults.key() === key
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}> <Show when={domain.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} /> <ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
</div> </div>
</div> </div>
) )
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
}) })
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
}) })
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted": case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input) pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source]) return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": { case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID)) const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID)) pending.delete(key(sessionID, event.data.inputID))
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker" import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server" import { useServerActionsController } from "@/components/server/server-management-controller"
import { useSettingsCommand } from "@/components/settings-dialog" import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
const serverManagement = useServerManagementController({ navigateOnAdd: false }) const serverManagement = useServerActionsController()
const [_state, setState, _, ready] = persisted( const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]), Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -56,12 +56,12 @@ export function createHomeProjectsController(home: HomeController) {
const key = ServerConnection.key(conn) const key = ServerConnection.key(conn)
setState("collapsed", key, !state().collapsed[key]) setState("collapsed", key, !state().collapsed[key])
}, },
canDefault: serverManagement.canDefault, canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaultKey, defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) => setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
canRemove: (conn: ServerConnection.Any) => serverManagement.canRemove(ServerConnection.key(conn)), canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />), edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer, focus: home.selection.focusServer,
}, },
+10 -12
View File
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js" import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server" import type { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -17,8 +17,6 @@ import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context" import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model" import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) { export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl" return server.type === "sidecar" && server.variant === "wsl"
} }
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const openAddWsl = () => { const openAddWsl = () => {
dialog.push(() => <DialogAddWslServer />) void dialog.push(() => <DialogAddWslServer />)
} }
return ( return (
<Show <Show
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
} }
export function WslServerSettings(props: { export function WslServerSettings(props: {
controller: Controller domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers> servers: ReturnType<typeof useFilteredWslServers>
}) { }) {
const platform = usePlatform() const platform = usePlatform()
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
})) }))
const remove = (key: ServerConnection.Key) => { const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key)) request.mutate(() => props.domain.connection.remove(key))
} }
return ( return (
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
return ( return (
<div class="settings-v2-servers-row"> <div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead"> <div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} /> <ServerHealthIndicator health={props.domain.collection.health()[key]} />
<div class="settings-v2-servers-copy"> <div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1"> <span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span> <span class="settings-v2-servers-name">{item.config.distro}</span>
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
</div> </div>
</div> </div>
<div class="settings-v2-servers-actions"> <div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag> <Tag>{language.t("dialog.server.status.default")}</Tag>
</Show> </Show>
<Show when={opencodeAction()}> <Show when={opencodeAction()}>
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")} {language.t("wsl.server.retryStart")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}> <Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}> <MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item> </MenuV2.Item>
</Show> </Show>
+27 -73
View File
@@ -263,52 +263,38 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info> export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E> export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = void export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type SessionPendingCancelOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input, input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E> ) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID } export type Endpoint5_25Input = {
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly key: InstructionEntry.Key readonly key: InstructionEntry.Key
readonly value: Schema.Json readonly value: Schema.Json
} }
export type Endpoint5_28Output = void export type Endpoint5_25Output = void
export type SessionInstructionsEntryPutOperation<E = never> = ( export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input, input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_28Output, E> ) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void export type Endpoint5_26Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = ( export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input, input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_29Output, E> ) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string } export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string } export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E> export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_31Input = { export type Endpoint5_28Input = {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined readonly follow?: boolean | undefined
} }
export type Endpoint5_31Output = export type Endpoint5_28Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
@@ -418,33 +404,6 @@ export type Endpoint5_31Output =
readonly input: SessionPending.Message readonly input: SessionPending.Message
} }
} }
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: DateTime.Utc
@@ -903,19 +862,19 @@ export type Endpoint5_31Output =
} }
) )
| EventLog.Synced | EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E> export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID } export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_32Output = void export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID } export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E> export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
@@ -943,12 +902,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E> readonly commit: SessionRevertCommitOperation<E>
} }
readonly context: SessionContextOperation<E> readonly context: SessionContextOperation<E>
readonly pending: { readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: { readonly instructions: {
readonly entry: { readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E> readonly list: SessionInstructionsEntryListOperation<E>
+21 -48
View File
@@ -80,12 +80,6 @@ import type {
Endpoint5_30Output, Endpoint5_30Output,
Endpoint5_31Input, Endpoint5_31Input,
Endpoint5_31Output, Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint5_33Input,
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -529,58 +523,37 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()( preserveEffect<Endpoint5_24Output>()(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_28Output>()( preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)), }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_29Output>()( preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
), ),
) )
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_30Output>()( preserveEffect<Endpoint5_27Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
), ),
) )
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_31Output>()( preserveStream<Endpoint5_28Output>()(
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@@ -592,18 +565,18 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
), ),
) )
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_32Output>()( preserveEffect<Endpoint5_29Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_33Output>()( preserveEffect<Endpoint5_30Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
) )
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_34Output>()( preserveEffect<Endpoint5_31Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@@ -632,13 +605,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw), wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw), context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) }, pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } }, instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_30(raw), generate: Endpoint5_27(raw),
log: Endpoint5_31(raw), log: Endpoint5_28(raw),
interrupt: Endpoint5_32(raw), interrupt: Endpoint5_29(raw),
background: Endpoint5_33(raw), background: Endpoint5_30(raw),
message: Endpoint5_34(raw), message: Endpoint5_31(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -58,12 +58,6 @@ import type {
SessionContextOutput, SessionContextOutput,
SessionPendingListInput, SessionPendingListInput,
SessionPendingListOutput, SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput, SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput, SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput, SessionInstructionsEntryPutInput,
@@ -772,39 +766,6 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
}, },
instructions: { instructions: {
entry: { entry: {
@@ -502,36 +502,6 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string } data: { sessionID: string; inputID: string }
} }
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = { export type SessionExecutionStarted = {
id: string id: string
created: number created: number
@@ -2000,9 +1970,6 @@ export type SessionEventDurable =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -2057,9 +2024,6 @@ export type V2Event =
| SessionForked | SessionForked
| SessionInputPromoted | SessionInputPromoted
| SessionInputAdmitted | SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted | SessionExecutionStarted
| SessionExecutionSucceeded | SessionExecutionSucceeded
| SessionExecutionFailed | SessionExecutionFailed
@@ -3725,27 +3689,6 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"] export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"] export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => { test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text() const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info") expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent") expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi") expect(source).not.toContain("HttpApiClient.ForApi")
}) })
-23
View File
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
"projectCopy", "projectCopy",
"vcs", "vcs",
"debug", "debug",
"migration",
"websearch", "websearch",
"config", "config",
]) ])
@@ -357,28 +356,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }]) expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
}) })
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => { test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
-2
View File
@@ -17,7 +17,6 @@
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"exports": { "exports": {
"./environment": "./src/environment/index.ts",
"./session/runner": "./src/session/runner/index.ts", "./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts", "./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
@@ -118,7 +117,6 @@
"immer": "11.1.4", "immer": "11.1.4",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"turndown": "7.2.0", "turndown": "7.2.0",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
+3 -31
View File
@@ -51,8 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings), ...mapGoogleOptions(input.settings),
}, },
} }
case "@ai-sdk/openai-compatible":
return mapOpenAICompatible(input.settings)
case "@openrouter/ai-sdk-provider": case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings) return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai": case "@ai-sdk/xai":
@@ -65,34 +63,6 @@ export function map(input: MapInput): Mapping | undefined {
}, },
} }
} }
return undefined
}
function mapOpenAICompatible(settings: Readonly<Record<string, unknown>>): Mapping | undefined {
if (typeof settings.baseURL !== "string") return undefined
if (
settings.timeout !== undefined ||
settings.headerTimeout !== undefined ||
settings.chunkTimeout !== undefined ||
settings.fetch !== undefined ||
settings.transformRequestBody !== undefined ||
settings.metadataExtractor !== undefined ||
settings.supportsStructuredOutputs === true ||
settings.strictJsonSchema !== undefined
)
return undefined
const options = typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : undefined
return {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
baseURL: settings.baseURL,
...(typeof settings.name === "string" ? { provider: settings.name } : {}),
...mapAPIKey(settings),
...(isStringRecord(settings.queryParams) ? { http: { query: settings.queryParams } } : {}),
...(options === undefined ? {} : { providerOptions: { openai: options } }),
},
...(isStringRecord(settings.headers) ? { headers: settings.headers } : {}),
}
} }
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined { function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
@@ -222,7 +192,9 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
} }
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) { function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {} return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
} }
function mapAPIKey(settings: Readonly<Record<string, unknown>>) { function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
+4 -6
View File
@@ -13,14 +13,12 @@ export const Plugin = define({
const config = yield* Config.Service const config = yield* Config.Service
const loaded = { entries: yield* config.entries() } const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => { yield* ctx.integration.transform((integrations) => {
const configuredIntegrations = new Set(
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
)
for (const [id, provider] of configuredProviders(loaded.entries)) { for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id const integrationID = id
if (!integrations.get(integrationID)) { if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
integrations.update(integrationID, (integration) => { integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name integration.name = provider.name ?? integration.name
}) })
-9
View File
@@ -1,9 +0,0 @@
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { FilesImpl } from "./files"
export interface Driver {
readonly spawner: ChildProcessSpawner["Service"]
readonly overrides?: Partial<FilesImpl>
}
export * as EnvironmentDriver from "./driver"
@@ -1,26 +0,0 @@
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Files } from "./files"
import { makeFiles } from "./index"
import { makeLocalDriver } from "./local"
export interface Interface {
readonly files: Files
readonly spawner: ChildProcessSpawner["Service"]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
export * as EnvironmentService from "./environment"
@@ -1,192 +0,0 @@
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { collectStream } from "@opencode-ai/util/process"
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
/**
* Files derived from spawning processes: one process per intent, "$1" is
* always the target path. Scripts report classification through an exit-code
* protocol (44/45/46) so failures never require parsing localized error text;
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
* findutils in the target image — BSD and busybox userlands will not work.
* Malformed output from these scripts is our own bug and dies as a defect.
*/
const MAX_DATA_BYTES = 64 * 1024 * 1024
const MAX_ERROR_BYTES = 64 * 1024
const NOT_FOUND = 44
const WRONG_KIND = 45
const FAILED = 46
const TAB = "\t"
const loadMetadata = (flags = "") => `
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
case "$metadata" in
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
esac
}
`
const statScript = `
${loadMetadata()}
printf '%s\n' "$metadata"
`
const readScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
printf '%s\n' "$metadata"
if [ "$2" = range ]; then
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
else
cat -- "$1"
fi
`
const listScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
${loadMetadata()}
mv -- "$1" "$2"
`
interface Result {
readonly exitCode: number
readonly stdout: Uint8Array
readonly stderr: Uint8Array
}
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
const run = (
path: string,
script: string,
args: ReadonlyArray<string> = [],
stdin?: Uint8Array,
): Effect.Effect<Result, Failed> =>
Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
env: { LC_ALL: "C" },
extendEnv: true,
stdin: stdin === undefined ? undefined : Stream.make(stdin),
})
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, MAX_DATA_BYTES),
collectStream(handle.stderr, MAX_ERROR_BYTES),
handle.exitCode,
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
if (stdout.truncated || stderr.truncated) {
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
}
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
}),
)
const classify = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
if (result.exitCode === WRONG_KIND) {
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
}
return Effect.fail(processFailure(path, result))
}
const complete = (path: string, result: Result) =>
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
return {
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
read: (path, range) =>
run(
path,
readScript,
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
).pipe(
Effect.flatMap((result) =>
classify(path, result, (stdout) => {
const newline = stdout.indexOf(10)
if (newline < 0) throw new Error("Missing read metadata header")
return {
info: parseInfo(stdout.slice(0, newline)),
bytes: stdout.slice(newline + 1),
}
}),
),
),
write: (path, bytes) =>
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
Effect.flatMap((result) => complete(path, result)),
),
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
move: (from, to) =>
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
}
}
/** `classify` for scripts whose protocol never reports WrongKind. */
const classifyPlain = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
return Effect.fail(processFailure(path, result))
}
const processFailure = (path: string, result: Result) =>
new Failed({
path,
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
})
const parseInfo = (bytes: Uint8Array): FileInfo => {
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
const size = Number(rawSize)
const mtimeMs = Number(rawMtime) * 1_000
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
return { type: parseType(rawType), size, mtimeMs }
}
const parseType = (value: string): FileType => {
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
if (value === "directory" || value === "d") return "directory"
if (value === "symbolic link" || value === "l") return "symlink"
return "other"
}
const parseList = (bytes: Uint8Array) => {
const fields = new TextDecoder().decode(bytes).split("\0")
fields.pop()
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
return Array.from({ length: fields.length / 2 }, (_, index) => ({
name: fields[index * 2 + 1],
type: parseType(fields[index * 2]),
}))
}
export * as EnvironmentExecDefaults from "./exec-defaults"
-70
View File
@@ -1,70 +0,0 @@
import { Effect, Schema } from "effect"
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
export type FileType = typeof FileType.Type
export interface FileInfo {
readonly type: FileType
readonly size: number
readonly mtimeMs: number
}
export interface DirEntry {
readonly name: string
readonly type: FileType
}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
path: Schema.String,
}) {}
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
path: Schema.String,
actual: FileType,
}) {}
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
path: Schema.String,
cause: Schema.Defect(),
}) {}
export interface FilesImpl {
/**
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
readonly read: (
path: string,
range?: { readonly offset: number; readonly length: number },
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
}
export interface Files extends FilesImpl {}
/**
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
* symlink fails with `NotFound`.
*/
export const typeFollowing = (files: Files, path: string) =>
files.stat(path).pipe(
Effect.flatMap((info) =>
info.type === "symlink"
? files.read(path, { offset: 0, length: 0 }).pipe(
Effect.map((result) => result.info.type),
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
)
: Effect.succeed(info.type),
),
)
export * as EnvironmentFiles from "./files"
-27
View File
@@ -1,27 +0,0 @@
export * as Environment from "./index"
export { type Driver } from "./driver"
export {
type DirEntry,
Failed,
type FileInfo,
type Files,
type FilesImpl,
type FileType,
NotFound,
typeFollowing,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeLocalDriver } from "./local"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
export { type Interface, node, Service } from "./environment"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
import type { Files } from "./files"
export const makeFiles = (driver: Driver): Files => ({
...execDefaults(driver.spawner),
...driver.overrides,
})
-103
View File
@@ -1,103 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
/**
* The host filesystem binding. Deliberately raw node:fs rather than effect's
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
* reports "symlink") and typed directory entries, and effect's node
* FileSystem provides neither — its stat always follows symlinks and
* readDirectory returns names only. FSUtil hits the same gap and its
* readDirectoryEntries already bypasses to raw node readdir internally.
* Nothing above the environment seam touches node:fs.
*/
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
const overrides: FilesImpl = {
read: (value, range) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
if (range === undefined) {
const bytes = yield* attempt(value, () => fs.readFile(value), true)
return { info, bytes }
}
const bytes = yield* attempt(
value,
async () => {
const handle = await fs.open(value, "r")
try {
const buffer = new Uint8Array(range.length)
const result = await handle.read(buffer, 0, range.length, range.offset)
return buffer.subarray(0, result.bytesRead)
} finally {
await handle.close()
}
},
true,
)
return { info, bytes }
}),
stat: (value) => stat(value, false),
list: (value) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
}),
write: (value, bytes) =>
attempt(value, async () => {
await fs.mkdir(path.dirname(value), { recursive: true })
await fs.writeFile(value, bytes)
}),
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
move: (from, to) =>
Effect.gen(function* () {
yield* stat(from, false)
const destination = yield* stat(to, false).pipe(
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
Effect.catchIf(
(error) => error instanceof NotFound,
() => Effect.succeed(to),
),
)
yield* attempt(from, () => fs.rename(from, destination))
}),
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
}
return { spawner, overrides }
}
const stat = (value: string, follow: boolean) =>
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
)
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
if (entry.isFile()) return "file"
if (entry.isDirectory()) return "directory"
if (entry.isSymbolicLink()) return "symlink"
return "other"
}
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
return Effect.tryPromise({
try: run,
catch: (cause) =>
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
})
}
const isMissing = (cause: unknown) =>
cause !== null &&
typeof cause === "object" &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
export * as EnvironmentLocal from "./local"
-168
View File
@@ -1,168 +0,0 @@
import path from "node:path"
import { Effect, PlatformError } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
type Node =
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
| { readonly type: "directory"; readonly mtimeMs: number }
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
export interface MemoryDriver extends Driver {
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
}
export const makeMemoryDriver = (): MemoryDriver => {
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
const key = (value: string) => path.posix.resolve("/", value)
const info = (node: Node): FileInfo => ({
type: node.type,
size:
node.type === "file"
? node.bytes.length
: node.type === "symlink"
? new TextEncoder().encode(node.target).length
: 0,
mtimeMs: node.mtimeMs,
})
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
const normalized = key(value)
const parts = normalized.split("/").filter(Boolean)
const base = "/"
const walk = (current: string, index: number): string | undefined => {
if (index === parts.length) return current
const part = parts[index]
const candidate = path.posix.join(current, part)
const node = nodes.get(candidate)
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
if (seen.has(candidate)) return undefined
seen.add(candidate)
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
}
return walk(base, 0)
}
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
const requireParent = (value: string) => {
const parentPath = path.posix.dirname(key(value))
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
}
const mkdirSync = (value: string) => {
const target = resolveKey(value, false) ?? key(value)
const existing = nodes.get(target)
if (existing?.type === "directory") return
if (existing) throw new Error(`Path is not a directory: ${value}`)
const parent = path.posix.dirname(target)
if (parent !== target) mkdirSync(parent)
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
mkdirSync(path.posix.dirname(key(value)))
const existing = lookup(value)
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
requireParent(target)
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
for (const entry of nodes.keys()) {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
const spawner = make((command) =>
Effect.suspend(() => {
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
return Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "EnvironmentMemory",
method: "spawn",
pathOrDescriptor: description,
cause: failed(description, new Error("The memory driver cannot spawn processes")),
}),
)
}),
)
return {
spawner,
overrides,
symlink: (target, value) =>
Effect.try({
try: () => {
requireParent(value)
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
}
}
export * as EnvironmentMemory from "./memory"
+12 -48
View File
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment"
import type { Files } from "./environment"
export interface Target { export interface Target {
readonly absolute: string readonly absolute: string
@@ -31,36 +29,13 @@ export interface WriteResult {
} }
export interface Interface { export interface Interface {
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */ readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
readonly withLock: (
targets: ReadonlyArray<string>,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: ( readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
input: TextWriteInput,
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
return Bom.decodeBytes((yield* files.read(target)).bytes)
})
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
files: Files,
target: string,
bom: boolean,
) {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
})
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/** /**
* Serialize file changes by absolute target. Conditional writes compare and * Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do * write under the same process-local lock so cooperating OpenCode mutations do
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const environment = yield* Environment.Service const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>() const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map(FSUtil.resolve))]
.sort()
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
const withTargetLock = const withTargetLock =
(target: Target) => (target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) => <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -90,14 +61,8 @@ const layer = Layer.effect(
const write = Effect.fn("FileMutation.write")((input: WriteInput) => const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)( withTargetLock(input.target)(
Effect.gen(function* () { Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe( const existed = yield* fs.exists(input.target.absolute)
Effect.as(true), yield* fs.writeWithDirs(input.target.absolute, input.content)
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
return writeResult(input.target, existed) return writeResult(input.target, existed)
}), }),
), ),
@@ -107,24 +72,23 @@ const layer = Layer.effect(
withTargetLock(input.target)( withTargetLock(input.target)(
Effect.gen(function* () { Effect.gen(function* () {
const next = Bom.split(input.content) const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe( const current = yield* fs
Effect.map((result) => result.bytes), .readFile(input.target.absolute)
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)), .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
) yield* fs.writeWithDirs(
yield* environment.files.write(
input.target.absolute, input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)), Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
) )
return writeResult(input.target, current !== undefined) return writeResult(input.target, current !== undefined)
}), }),
), ),
) )
return Service.of({ withLock, write, writeTextPreservingBom }) return Service.of({ write, writeTextPreservingBom })
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/** /**
* Deferred until the corresponding integrations exist. * Deferred until the corresponding integrations exist.
-2
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node" import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus" import { Bus } from "./bus"
import { FileMutation } from "./file-mutation" import { FileMutation } from "./file-mutation"
import { Environment } from "./environment"
import { Formatter } from "./formatter" import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search" import { FileSystemSearch } from "./filesystem/search"
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [ const locationServiceNodes = [
Location.node, Location.node,
Environment.node,
Config.node, Config.node,
Agent.node, Agent.node,
Command.node, Command.node,
+13
View File
@@ -5,6 +5,8 @@ import { LanguageModel } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import // ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import // ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route" import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
@@ -162,6 +164,17 @@ export const fromCatalogModel = (
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
) )
} }
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata } const configured = { ...resolved.settings, ...credential?.metadata }
const mapping = Provider.isAISDK(resolved.package) const mapping = Provider.isAISDK(resolved.package)
? AISDKNative.map({ ? AISDKNative.map({
-3
View File
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter" import { Formatter } from "../formatter"
import { Form } from "../form" import { Form } from "../form"
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const credential = yield* Credential.Service const credential = yield* Credential.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service const filesystem = yield* FileSystem.Service
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config), Context.make(Config.Service, config),
Context.make(Credential.Service, credential), Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus), Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation), Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter), Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem), Context.make(FileSystem.Service, filesystem),
+1 -5
View File
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform" import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter" import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
@@ -283,9 +282,7 @@ const layer = Layer.effect(
}) })
const updates = Stream.merge( const updates = Stream.merge(
config.changes().pipe( config.changes().pipe(
Stream.filterEffect((update) => Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.merge(Stream.fromPubSub(configuredChanges)), Stream.merge(Stream.fromPubSub(configuredChanges)),
), ),
bus.subscribe([Event.Updated, SdkPlugins.Updated]), bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
Config.node, Config.node,
Credential.node, Credential.node,
Bus.node, Bus.node,
Environment.node,
FileMutation.node, FileMutation.node,
Formatter.node, Formatter.node,
FileSystem.node, FileSystem.node,
@@ -1,84 +0,0 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { WebSearchExa } from "./exa" import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel" import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
+5 -7
View File
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem" import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { collectStream, waitForAbort } from "@opencode-ai/util/process" import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { Environment } from "./environment"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary" import { RipgrepBinary } from "./ripgrep/binary"
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const environment = yield* Environment.Service const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service const binary = yield* RipgrepBinary.Service
const run = <A>(input: { const run = <A>(input: {
@@ -108,8 +107,7 @@ const layer = Layer.effect(
}) => { }) => {
const program = Effect.scoped( const program = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam. const handle = yield* process.spawn(
const handle = yield* environment.spawner.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }), ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
) )
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
@@ -277,4 +275,4 @@ const layer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] }) export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
-39
View File
@@ -133,14 +133,6 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} }) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", { export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID, skill: Skill.ID,
}) {} }) {}
@@ -189,9 +181,6 @@ export interface Interface {
* unhandled compaction barriers. * unhandled compaction barriers.
*/ */
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError> readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/** /**
* Durable, ordered session log read. Replays durable session bus after * Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured * the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -329,31 +318,6 @@ const layer = Layer.effect(
), ),
) )
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? pendingConflict(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({ const result = Service.of({
create: Effect.fn("Session.create")(function* (input) { create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create() const sessionID = input.id ?? SessionSchema.ID.create()
@@ -543,9 +507,6 @@ const layer = Layer.effect(
yield* result.get(sessionID) yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID) return yield* SessionPending.list(db, sessionID)
}), }),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) => log: (input) =>
Stream.unwrap( Stream.unwrap(
result result
+1 -16
View File
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution" import { SessionExecution } from "../execution"
import { SessionStore } from "../store" import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
export interface Interface { export interface Interface {
/** /**
* Marks every execution active in this process for resumption by the next server start. * Marks every execution active in this process for resumption by the next server start.
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({ return Service.of({
suspendActiveSessions: Effect.gen(function* () { suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active) yield* store.suspend(yield* execution.active)
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
(sessionID) => (sessionID) =>
Effect.gen(function* () { Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer. // Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID)) yield* Effect.ignore(execution.resume(sessionID))
}), }),
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
}), }),
) )
export const node = makeGlobalNode({ export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
service: Service,
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
@@ -90,9 +90,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void, "session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void, "session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void, "session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry, "session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry, "session.execution.failed": () => clearCurrentRetry,
+4 -84
View File
@@ -37,7 +37,6 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData) const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>() const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()( export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict", "SessionPending.LifecycleConflict",
@@ -295,7 +294,10 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
*/ */
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* ( export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: PendingRef, input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) { ) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db const deleted = yield* db
@@ -310,55 +312,6 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored return stored
}) })
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* ( export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService, db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID }, input: { readonly sessionID: SessionSchema.ID },
@@ -436,39 +389,6 @@ export const equivalent = (
return false return false
} }
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* ( const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService, db: DatabaseService,
bus: Bus.Interface, bus: Bus.Interface,
-18
View File
@@ -485,24 +485,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) => yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.durable === undefined) if (event.durable === undefined)
+270 -269
View File
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer" import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell" import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config" import { Config } from "./config"
import { Bus } from "./bus" import { Bus } from "./bus"
import { Environment } from "./environment"
import { Location } from "./location" import { Location } from "./location"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select" import { ShellSelect } from "./shell/select"
@@ -65,284 +65,285 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) => export const layer = (options?: ShellSelect.Options) => Layer.effect(
Layer.effect( Service,
Service, Effect.gen(function* () {
Effect.gen(function* () { const bus = yield* Bus.Service
const bus = yield* Bus.Service const location = yield* Location.Service
const location = yield* Location.Service const config = yield* Config.Service
const config = yield* Config.Service const global = yield* Global.Service
const global = yield* Global.Service const appProcess = yield* AppProcess.Service
const environment = yield* Environment.Service const hooks = yield* PluginHooks.Service
const hooks = yield* PluginHooks.Service const context = yield* Effect.context()
const context = yield* Effect.context() const runFork = Effect.runForkWith(context)
const runFork = Effect.runForkWith(context) const sessions = new Map<string, Active>()
const sessions = new Map<string, Active>() const exitOrder: string[] = []
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id) const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises")) const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs")) const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true })) yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
for (const session of sessions.values()) { for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved. // Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) })) yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
} }
sessions.clear() sessions.clear()
exitOrder.length = 0 exitOrder.length = 0
}), }),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
) )
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) { const session = yield* Deferred.await(ready)
const session = sessions.get(id) return session.info
if (!session) return yield* new NotFoundError({ id }) })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) { return Service.of({ name, create, list, get, wait, timeout, output, remove })
const session = sessions.get(id) }),
if (!session) return )
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
export function configured(options?: ShellSelect.Options) { export function configured(options?: ShellSelect.Options) {
return makeLocationNode({ return makeLocationNode({
service: Service, service: Service,
layer: layer(options), layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node], deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
}) })
} }
+20 -60
View File
@@ -2,7 +2,7 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema, Scope, Stream, Types } from "effect" import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill" import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent" import { Agent } from "./agent"
@@ -13,7 +13,6 @@ import { Permission } from "./permission"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery" import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state" import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource export type DirectorySource = Skill.DirectorySource
@@ -82,51 +81,6 @@ const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const scope = yield* Scope.Scope
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watched = new Set<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return
for (const [key] of invalidated) cache.delete(key)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
const watch = Effect.fn("Skill.watch")(function* (directory: string) {
const target = path.resolve(directory)
if (watched.has(target)) return
watched.add(target)
const updates = yield* watcher.subscribe({ path: target, type: "directory" })
yield* updates.pipe(
Stream.runForEach((update) => invalidate(update.path)),
Effect.forkIn(scope, { startImmediately: true }),
)
})
const watchDirectory = Effect.fn("Skill.watchDirectory")(function* (directory: string) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved)
if (resolved !== target) {
yield* watch(path.dirname(target))
}
return resolved === target ? [target] : [target, resolved]
}
if (yield* fs.isDir(path.dirname(target))) {
yield* watch(path.dirname(target))
}
return [target]
})
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
name: "skill", name: "skill",
@@ -138,8 +92,7 @@ const layer = Layer.effect(
}, },
list: () => draft.sources as Source[], list: () => draft.sources as Source[],
}), }),
finalize: () => finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
Effect.sync(() => cache.clear()).pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
}) })
const load = Effect.fn("Skill.load")(function* (source: Source) { const load = Effect.fn("Skill.load")(function* (source: Source) {
@@ -151,22 +104,14 @@ const layer = Layer.effect(
directories: [], directories: [],
skills: [source.skill.id], skills: [source.skill.id],
}) })
return { skills: [source.skill], paths: [] } return { skills: [source.skill], directories: [] }
} }
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) { for (const directory of directories) {
const files = yield* fs const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true }) .scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) .pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) { for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external)
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue if (!content) continue
const markdown = ConfigMarkdown.parseOption(content) const markdown = ConfigMarkdown.parseOption(content)
@@ -194,7 +139,22 @@ const layer = Layer.effect(
directories, directories,
skills: skills.map((skill) => skill.id), skills: skills.map((skill) => skill.id),
}) })
return { skills, paths } return { skills, directories }
})
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
)
if (invalidated.length === 0) return
for (const [key] of invalidated) cache.delete(key)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
}) })
yield* bus.subscribe(FileSystem.Event.Changed).pipe( yield* bus.subscribe(FileSystem.Event.Changed).pipe(
@@ -227,5 +187,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node], deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
}) })
+17 -20
View File
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter" import { Formatter } from "../../formatter"
import { Location } from "../../location" import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { fileDiff } from "./file-diff" import { fileDiff } from "./file-diff"
@@ -111,10 +109,9 @@ export const Plugin = {
id: "opencode.tool.edit", id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service const files = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const location = yield* Location.Service const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -155,16 +152,17 @@ export const Plugin = {
}) })
} }
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe( const info = yield* fs
Effect.catchTag("Environment.NotFound", () => .stat(target.absolute)
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })), .pipe(
), Effect.catchReason("PlatformError", "NotFound", () =>
Effect.catchTag("Environment.WrongKind", (error) => Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
error.actual === "directory" ),
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })) )
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })), if (info.type === "Directory") {
), return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
) }
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text const source = original.text
const ending = source.includes(crlf) ? crlf : "\n" const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending) const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -206,20 +204,19 @@ export const Plugin = {
}) })
} }
const replacementBom = replaced.startsWith("\uFEFF") const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* fileMutation.write({ const result = yield* files.write({
target, target,
content: Bom.join(replaced, original.bom || replacementBom), content: Bom.join(replaced, original.bom || replacementBom),
}) })
const bom = original.bom || replacementBom const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute)) const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom) ? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* FileMutation.readText(environment.files, target.absolute)).text : (yield* Bom.readFile(fs, target.absolute)).text
return { return {
files: [fileDiff(result.resource, source, formatted)], files: [fileDiff(result.resource, source, formatted)],
replacements, replacements,
} satisfies Output } satisfies Output
}).pipe( }).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
Effect.map((output) => ({ Effect.map((output) => ({
output, output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`, content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+12 -10
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep" import { Ripgrep } from "../../ripgrep"
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.glob", id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
@@ -82,20 +82,22 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe( const info = yield* fs
Effect.catchTag("Environment.NotFound", () => .stat(target.absolute)
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })), .pipe(
), Effect.catchReason("PlatformError", "NotFound", () =>
) Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
if (type !== "directory") ),
)
if (info.type !== "Directory")
return yield* Effect.fail( return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }), new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
) )
const root = target.absolute const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep const entries = yield* ripgrep
.glob({ .glob({
cwd: root, cwd: target.absolute,
pattern: input.pattern, pattern: input.pattern,
limit: limit + 1, limit: limit + 1,
}) })
+94 -90
View File
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import path from "path" import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem" import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location" import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
export const name = "grep" export const name = "grep"
export const Input = Schema.Struct({ export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern pattern: FileSystem.GrepInput.fields.pattern.check(
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" })) Schema.isMinLength(1, { message: "Pattern must not be empty" }),
.annotate({ ).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)", description: "Regular expression to search for in file contents (ripgrep syntax)",
}), }),
path: Schema.optionalKey(RelativePath).annotate({ path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.", description: "File or directory to search. Defaults to the current working directory.",
}), }),
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.grep", id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
@@ -66,100 +66,104 @@ export const Plugin = {
yield* ctx.tool yield* ctx.tool
.transform((draft) => .transform((draft) =>
draft.add({ draft.add(
name, ({
options: { codemode: false }, name,
description: options: { codemode: false },
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.", description:
input: Input, "Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
output: Output, input: Input,
execute: (input, context) => output: Output,
Effect.gen(function* () { execute: (input, context) =>
const source = { type: "tool" as const, messageID: context.messageID, id: context.id } Effect.gen(function* () {
const target = yield* mutation.resolve({ path: input.path ?? "." }) const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
if (target.externalDirectory) const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({ yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory), action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
}) })
yield* permission.assert({ const root = path.resolve(location.directory, input.path ?? ".")
action: name, const info = yield* fs
resources: [input.pattern], .stat(root)
save: ["*"], .pipe(
metadata: { Effect.catchReason("PlatformError", "NotFound", () =>
root: ".", Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
path: input.path, ),
include: input.include, )
limit: input.limit, const cwd = info?.type === "Directory" ? root : path.dirname(root)
}, const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
sessionID: context.sessionID, const matches = yield* ripgrep
agent: context.agent, .grep({
source, cwd,
}) pattern: input.pattern,
const root = target.absolute file: info?.type === "File" ? path.basename(root) : undefined,
const type = yield* Environment.typeFollowing(environment.files, root).pipe( include: input.include,
Effect.catchTag("Environment.NotFound", () => limit: limit + 1,
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })), })
), .pipe(
) Effect.timeoutOrElse({
const cwd = type === "directory" ? root : path.dirname(root) duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT orElse: () =>
const matches = yield* ripgrep Effect.fail(
.grep({ new ToolFailure({
cwd, message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
pattern: input.pattern, }),
file: type === "file" ? path.basename(root) : undefined, ),
include: input.include, }),
limit: limit + 1, Effect.map((result) =>
}) result.map((match) =>
.pipe( FileSystem.Match.make({
Effect.timeoutOrElse({ ...match,
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS, entry: FileSystem.Entry.make({
orElse: () => ...match.entry,
Effect.fail( path: RelativePath.make(
new ToolFailure({ path.relative(location.directory, path.resolve(cwd, match.entry.path)),
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`, ),
}),
}), }),
), ),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
), ),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
), ),
) metadata: { matches: result.matches.length, truncated: result.truncated },
return { matches: matches.slice(0, limit), truncated: matches.length > limit } })),
}).pipe( Effect.mapError((error) =>
Effect.map((result) => ({ error instanceof ToolFailure
output: result.matches, ? error
content: toModelContent( : error instanceof Ripgrep.InvalidPatternError
result.matches.map((match) => ({ ? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }), : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
), ),
), }),
}), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
+42 -41
View File
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Result, Schema } from "effect" import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path" import path from "path"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "../../environment"
import { Formatter } from "../../formatter" import { Formatter } from "../../formatter"
import { FileMutation } from "../../file-mutation"
import { Location } from "../../location" import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch" import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission" import { Permission } from "../../permission"
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
].join("\n") ].join("\n")
type Prepared = type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & { | (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: Target readonly target: Target
readonly before: string readonly before: string
readonly after: string readonly after: string
@@ -76,8 +69,7 @@ interface Target {
export const Plugin = { export const Plugin = {
id: "opencode.tool.patch", id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service const fs = yield* FSUtil.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const location = yield* Location.Service const location = yield* Location.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
@@ -92,13 +84,6 @@ export const Plugin = {
output: Output, output: Output,
execute: (input, context) => { execute: (input, context) => {
const applied: Array<typeof Applied.Type> = [] const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
])
: []
const fail = (operation: string, error: unknown) => { const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ") const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({ return new ToolFailure({
@@ -112,7 +97,7 @@ export const Plugin = {
id: context.id, id: context.id,
} }
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(parsed).pipe( const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })), Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
) )
if (hunks.length === 0) { if (hunks.length === 0) {
@@ -140,19 +125,18 @@ export const Plugin = {
}) })
} }
if (hunk.type === "add") { if (hunk.type === "add") {
const content =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({ prepared.push({
...hunk, ...hunk,
target, target,
content,
before: "", before: "",
after: Bom.split(content).text, after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
}) })
return return
} }
if (hunk.type === "delete") { if (hunk.type === "delete") {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe( const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
@@ -167,7 +151,20 @@ export const Plugin = {
const original = const original =
previous ?? previous ??
(yield* Effect.gen(function* () { (yield* Effect.gen(function* () {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe( const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new ToolFailure({ new ToolFailure({
@@ -236,8 +233,13 @@ export const Plugin = {
(change) => (change) =>
Effect.gen(function* () { Effect.gen(function* () {
if (change.type === "add") { if (change.type === "add") {
yield* environment.files yield* fs
.write(change.target.absolute, new TextEncoder().encode(change.content)) .writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({ applied.push({
type: change.type, type: change.type,
@@ -247,7 +249,7 @@ export const Plugin = {
return return
} }
if (change.type === "delete") { if (change.type === "delete") {
yield* environment.files yield* fs
.remove(change.target.absolute) .remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({ applied.push({
@@ -259,10 +261,10 @@ export const Plugin = {
} }
if (change.moveTarget) { if (change.moveTarget) {
const moveTarget = change.moveTarget const moveTarget = change.moveTarget
yield* environment.files yield* fs
.write(moveTarget.absolute, new TextEncoder().encode(change.content)) .writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* environment.files yield* fs
.remove(change.target.absolute) .remove(change.target.absolute)
.pipe( .pipe(
Effect.mapError((error) => Effect.mapError((error) =>
@@ -276,8 +278,8 @@ export const Plugin = {
}) })
return return
} }
yield* environment.files yield* fs
.write(change.target.absolute, new TextEncoder().encode(change.content)) .writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error))) .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({ applied.push({
type: change.type, type: change.type,
@@ -292,13 +294,13 @@ export const Plugin = {
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))], [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) => (target) =>
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* FileMutation.readText(environment.files, target).pipe( const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)), Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
) )
formatted.set( formatted.set(
target, target,
(yield* formatter.file(target)) (yield* formatter.file(target))
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe( ? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)), Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
) )
: current.text, : current.text,
@@ -313,7 +315,6 @@ export const Plugin = {
}) })
return { applied, files } return { applied, files }
}).pipe( }).pipe(
mutation.withLock(lockTargets),
Effect.map((output) => ({ Effect.map((output) => ({
output, output,
content: toModelOutput(output), content: toModelOutput(output),
@@ -344,10 +345,10 @@ export const Plugin = {
} }
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (error instanceof Environment.NotFound) return "file does not exist" if (error instanceof PlatformError) {
if (error instanceof Environment.WrongKind) if (error.reason._tag === "NotFound") return "file does not exist"
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}` return error.reason.description ?? error.reason.message
if (error instanceof Environment.Failed) return errorMessage(error.cause) }
return error instanceof Error ? error.message : String(error) return error instanceof Error ? error.message : String(error)
} }
+11 -8
View File
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
import { SessionInstructions } from "../../session/instructions" import { SessionInstructions } from "../../session/instructions"
import { AbsolutePath } from "../../schema" import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../read-filesystem" import { ReadToolFileSystem } from "../read-filesystem"
import { Environment } from "../../environment"
export const name = "read" export const name = "read"
const FILENAME = "AGENTS.md" const FILENAME = "AGENTS.md"
@@ -73,12 +72,16 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe( const type = yield* reader
Effect.catchIf( .inspect(absolute)
(error) => error instanceof Environment.NotFound, .pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
() => missing(input.path, target.absolute), const content =
), type === "directory"
) ? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location // After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a // root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md // directory listing the walk starts at the directory itself (so its own AGENTS.md
@@ -92,7 +95,7 @@ export const Plugin = {
// supplied by core initial instructions) is dropped by the dirname filter. // supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({ const discovered = yield* fs.up({
targets: [FILENAME], targets: [FILENAME],
start: content.type === "list-page" ? resolved : dirname(resolved), start: type === "directory" ? resolved : dirname(resolved),
stop: root, stop: root,
}) })
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter( const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
+10 -8
View File
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool" import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect" import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "../../config" import { Config } from "../../config"
import { Environment } from "../../environment"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission" import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime" import { PluginRuntime } from "../../plugin/runtime"
@@ -83,7 +83,7 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const environment = yield* Environment.Service const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
@@ -179,12 +179,14 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe( const workdir = yield* fsUtil
Effect.catchTag("Environment.NotFound", () => .stat(target.absolute)
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)), .pipe(
), Effect.catchReason("PlatformError", "NotFound", () =>
) Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
if (workdir !== "directory") ),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`)) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}), }),
) )
+8 -10
View File
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai" import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom" import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment" import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation" import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter" import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation" import { LocationMutation } from "../../location-mutation"
@@ -47,9 +47,9 @@ export const Plugin = {
id: "opencode.tool.write", id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service const files = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -77,8 +77,8 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe( const current = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
) )
const next = Bom.split(input.content) const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added") const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
@@ -91,11 +91,9 @@ export const Plugin = {
agent: context.agent, agent: context.agent,
source, source,
}) })
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content }) const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) { if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result return result
}).pipe( }).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.map((output) => ({ output, content: toModelOutput(output) })),
+191 -152
View File
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { lookup } from "mime-types"
import { Environment } from "../environment"
import type { Files } from "../environment"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { Mime } from "../mime" import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema" import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000 export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024 export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024 export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const FIRST_CHUNK = 256 * 1024
const MAX_LINE_LENGTH = 2_000 const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", { export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
resource: Schema.String, resource: Schema.String,
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
} }
} }
export type ReadError = export type InspectError = FSUtil.Error | PathKindError
| Environment.NotFound export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
| Environment.Failed
| BinaryFileError
| MediaIngestLimitError
| OffsetOutOfRangeError
| PathKindError
export const PageInput = Schema.Struct({ export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt), offset: Schema.optionalKey(NonNegativeInt),
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {} }) {}
export interface Interface { export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
readonly read: ( readonly read: (
path: AbsolutePath, path: AbsolutePath,
resource: string, resource: string,
page?: PageInput, page?: PageInput,
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError> ) => Effect.Effect<FileContent | TextPage, ReadError>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const mimeType = (value: string) => lookup(value) || "application/octet-stream" const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (bytes: Uint8Array) => {
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
return type
})
export const read = Effect.fn("ReadTool.read")(function* ( export const read = Effect.fn("ReadTool.read")(function* (
files: Files, fs: FSUtil.Interface,
input: AbsolutePath, input: string,
resource: string, resource: string,
page: PageInput = {}, page: PageInput = {},
) { ) {
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe( const real = yield* fs.realPath(input)
Effect.catchTag("Environment.WrongKind", (error) => { return yield* Effect.scoped(
if (error.actual !== "directory") Effect.gen(function* () {
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" })) const file = yield* fs.open(real, { flag: "r" })
return files.list(input).pipe( const info = yield* file.stat
Effect.map((entries) => list(entries, page)), if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
Effect.catchTag("Environment.WrongKind", () => const first = Option.getOrElse(
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })), yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
), () => new Uint8Array(),
) )
const mime = mediaMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const,
mime,
}
}
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(decodeUtf8(decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder()
let pending = ""
let discard = false
let line = 1
let bytes = 0
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return true
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
next = line
return false
}
lines.push(text)
bytes += size
line++
return true
}
const consume = (input: string) => {
let text = input
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
}
return true
}
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
let start = 0
while (start < chunk.length) {
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = decodeUtf8(decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated: next !== undefined,
...(next === undefined ? {} : { next }),
})
}), }),
) )
if (first instanceof ListPage) return first
const media = Mime.detect(first.bytes)
if (MEDIA_MIMES.has(media)) {
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
const whole = yield* readFile(files, input, resource)
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: Buffer.from(whole.bytes).toString("base64"),
encoding: "base64" as const,
mime: media,
}
}
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: new TextDecoder().decode(first.bytes),
encoding: "utf8" as const,
mime: mimeType(input),
}
}
const chunks = [first.bytes]
while (true) {
const bytes = Buffer.concat(chunks)
const eof = bytes.length >= first.info.size
const result = textPage(bytes, eof, page)
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
if (next.bytes.length === 0) {
const result = textPage(bytes, true, page)
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
return yield* makeTextPage(bytes, input, resource, result)
}
chunks.push(next.bytes)
}
}) })
const readFile = ( export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
files: Files, const real = yield* fs.realPath(input)
input: AbsolutePath, const items = yield* fs.readDirectoryEntries(real)
resource: string,
range?: { readonly offset: number; readonly length: number },
) =>
files
.read(input, range)
.pipe(
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
)
const makeTextPage = Effect.fnUntraced(function* (
bytes: Uint8Array,
input: AbsolutePath,
resource: string,
result: NonNullable<ReturnType<typeof textPage>>,
) {
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset })
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
mime: mimeType(input),
offset: result.offset,
truncated: result.next !== undefined,
...(result.next === undefined ? {} : { next: result.next }),
})
})
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
const offset = page.offset || 1 const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES) const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = items const visible = items
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
truncated, truncated,
...(truncated ? { next: offset + selected.length } : {}), ...(truncated ? { next: offset + selected.length } : {}),
}) })
} })
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const decoded = new TextDecoder().decode(bytes)
const split = decoded.split("\n")
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const entries: string[] = []
let size = 0
let next: number | undefined
for (const [index, value] of available.slice(offset - 1).entries()) {
const line = offset + index
if (entries.length >= limit || size >= MAX_READ_BYTES) {
next = line
break
}
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
if (size + lineSize > MAX_READ_BYTES) {
next = line
break
}
entries.push(text)
size += lineSize
}
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
next = offset + entries.length
if (!eof && next === undefined) return
const consumedLines = next === undefined ? available.length : next - 1
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
return { entries, offset, next, consumed }
}
const nthNewline = (bytes: Uint8Array, count: number) => {
let found = 0
for (const [index, byte] of bytes.entries()) {
if (byte !== 10) continue
found++
if (found === count) return index + 1
}
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const environment = yield* Environment.Service const fs = yield* FSUtil.Service
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) }) return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}), }),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] }) export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
+5 -7
View File
@@ -248,7 +248,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
body: info.options && options.body, body: info.options && options.body,
models: models:
info.models && info.models &&
Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model, info.npm)])), Object.fromEntries(Object.entries(info.models).map(([name, model]) => [name, migrateModel(model)])),
} }
} }
@@ -294,9 +294,8 @@ export function providerID(input: string) {
return input return input
} }
function migrateModel(info: typeof ConfigProviderV1.Model.Type, inheritedPackage?: string) { function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
const packageName = info.provider?.npm ?? inheritedPackage const settings = info.options && ConfigProviderOptionsV1.model(info.options)
const overlays = info.options && ConfigProviderOptionsV1.modelOverlays(info.options, packageName)
const costs = info.cost && [ const costs = info.cost && [
{ {
input: info.cost.input, input: info.cost.input,
@@ -324,15 +323,14 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, inheritedPackage
name: info.name, name: info.name,
compatibility: Model.compatibility(info.interleaved), compatibility: Model.compatibility(info.interleaved),
package: info.provider?.npm ? Provider.aisdk(info.provider.npm) : undefined, package: info.provider?.npm ? Provider.aisdk(info.provider.npm) : undefined,
settings: info.provider?.api ? { ...overlays?.settings, baseURL: info.provider.api } : overlays?.settings, settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
body: overlays?.body,
capabilities, capabilities,
headers: info.headers, headers: info.headers,
variants: variants:
info.variants && info.variants &&
Object.entries(info.variants).map(([id, options]) => ({ Object.entries(info.variants).map(([id, options]) => ({
id, id,
...ConfigProviderOptionsV1.modelOverlays(options, packageName), settings: ConfigProviderOptionsV1.model(options),
})), })),
cost: costs, cost: costs,
disabled: info.status === "deprecated" ? true : undefined, disabled: info.status === "deprecated" ? true : undefined,
@@ -29,18 +29,3 @@ export function provider(options: Options): ProviderResult {
export function model(options: Options) { export function model(options: Options) {
return { ...options } return { ...options }
} }
export function modelOverlays(options: Options, packageName: string | undefined) {
if (packageName !== "@ai-sdk/openai-compatible") return { settings: model(options) }
const known = new Set(["reasoningEffort", "strictJsonSchema"])
const settings = Object.fromEntries(Object.entries(options).filter(([key]) => known.has(key)))
const body = Object.fromEntries(
Object.entries(options)
.filter(([key]) => !known.has(key))
.map(([key, value]) => [key === "textVerbosity" ? "verbosity" : key, value]),
)
return {
settings: Object.keys(settings).length === 0 ? undefined : settings,
body: Object.keys(body).length === 0 ? undefined : body,
}
}
-43
View File
@@ -5,49 +5,6 @@ const map = (packageName: string, settings: Readonly<Record<string, unknown>>, m
AISDKNative.map({ packageName, settings, modelID }) AISDKNative.map({ packageName, settings, modelID })
describe("AISDKNative", () => { describe("AISDKNative", () => {
test("maps the generic OpenAI-compatible package to the native provider package", () => {
expect(
map("@ai-sdk/openai-compatible", {
apiKey: "secret",
baseURL: "https://compatible.example/v1",
name: "example",
headers: { "x-test": "value" },
queryParams: { tenant: "one" },
reasoningEffort: "high",
}),
).toEqual({
package: "@opencode-ai/ai/providers/openai-compatible",
settings: {
apiKey: "secret",
baseURL: "https://compatible.example/v1",
provider: "example",
http: { query: { tenant: "one" } },
providerOptions: {
openai: {
reasoningEffort: "high",
},
},
},
headers: { "x-test": "value" },
})
expect(map("@ai-sdk/openai-compatible", {})).toBeUndefined()
expect(
map("@ai-sdk/openai-compatible", { baseURL: "https://compatible.example/v1", timeout: 30_000 }),
).toBeUndefined()
expect(
map("@ai-sdk/openai-compatible", {
baseURL: "https://compatible.example/v1",
supportsStructuredOutputs: true,
}),
).toBeUndefined()
expect(
map("@ai-sdk/openai-compatible", {
baseURL: "https://compatible.example/v1",
strictJsonSchema: false,
}),
).toBeUndefined()
})
test("maps both models.dev Bedrock packages to native providers", () => { test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({ expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock", package: "@opencode-ai/ai/providers/amazon-bedrock",
-66
View File
@@ -570,72 +570,6 @@ describe("Config", () => {
}), }),
) )
it.effect("preserves serializable OpenAI-compatible options across v1 migration", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
acme: {
npm: "@ai-sdk/openai-compatible",
api: "https://api.example/v1",
options: {
apiKey: "secret",
name: "acme",
headers: { "x-provider": "yes" },
body: { provider_body_extension: true },
queryParams: { tenant: "one" },
includeUsage: false,
supportsStructuredOutputs: true,
},
models: {
chat: {
options: {
user: "user-1",
reasoningEffort: "high",
textVerbosity: "low",
strictJsonSchema: false,
vendor_extension: { enabled: true },
},
variants: {
strict: { strictJsonSchema: true, variant_extension: "value" },
},
},
},
},
},
})
expect(migrated.providers?.acme).toMatchObject({
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: {
apiKey: "secret",
name: "acme",
queryParams: { tenant: "one" },
includeUsage: false,
supportsStructuredOutputs: true,
baseURL: "https://api.example/v1",
},
headers: { "x-provider": "yes" },
body: { provider_body_extension: true },
models: {
chat: {
settings: {
reasoningEffort: "high",
strictJsonSchema: false,
},
body: { user: "user-1", verbosity: "low", vendor_extension: { enabled: true } },
variants: [
{
id: "strict",
settings: { strictJsonSchema: true },
body: { variant_extension: "value" },
},
],
},
},
})
}),
)
it.effect("renames old provider IDs while migrating v1 configuration", () => it.effect("renames old provider IDs while migrating v1 configuration", () =>
Effect.sync(() => { Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({ const migrated = ConfigMigrateV1.migrate({
@@ -38,28 +38,6 @@ describe("ConfigProviderOptionsV1", () => {
}) })
}) })
test("splits OpenAI-compatible model options into native settings and body extensions", () => {
expect(
ConfigProviderOptionsV1.modelOverlays(
{
user: "user-1",
reasoningEffort: "high",
textVerbosity: "low",
strictJsonSchema: false,
vendor_extension: { enabled: true },
store: false,
},
"@ai-sdk/openai-compatible",
),
).toEqual({
settings: {
reasoningEffort: "high",
strictJsonSchema: false,
},
body: { user: "user-1", verbosity: "low", vendor_extension: { enabled: true }, store: false },
})
})
test("uses mechanical lowering for custom provider options", () => { test("uses mechanical lowering for custom provider options", () => {
expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({ expect(ConfigProviderOptionsV1.provider({ enabled: true })).toEqual({
settings: { enabled: true }, settings: { enabled: true },
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => { describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () => it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
-88
View File
@@ -1,88 +0,0 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import {
execDefaults,
Failed,
makeFiles,
makeLocalDriver,
makeMemoryDriver,
NotFound,
typeFollowing,
} from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
import { it } from "./lib/effect"
describe("typeFollowing", () => {
it.effect("follows symlinks without changing stat semantics", () =>
Effect.gen(function* () {
const driver = makeMemoryDriver()
const files = makeFiles(driver)
yield* files.mkdir("/directory")
yield* files.write("/file", new Uint8Array())
yield* driver.symlink("/directory", "/directory-link")
yield* driver.symlink("/file", "/file-link")
yield* driver.symlink("/missing", "/dangling-link")
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
return {
files: makeFiles(driver),
root: `/workspace-${crypto.randomUUID()}`,
symlink: driver.symlink,
}
}),
)
environmentConformance("local environment", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
return {
files: makeFiles(makeLocalDriver(spawner)),
root: tmp.path,
...(process.platform === "win32"
? {}
: {
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
environmentConformance(
"GNU exec environment",
() =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
return {
files: execDefaults(spawner),
root: tmp.path,
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
process.platform !== "linux",
)
+12 -63
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) { function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
return Effect.provide( return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation], [Location.node, activeLocation],
[Environment.node, environmentLayer], [FSUtil.node, filesystemLayer],
]), ]),
) )
} }
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
), ),
) )
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const target = path.join(directory, "shared.txt")
const first = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
}).pipe(provide(directory), Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
}).pipe(provide(directory), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const files = yield* FileMutation.Service
const first = yield* files
.withLock([path.join(directory, "first.txt")])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
}).pipe(provide(directory)),
),
)
it.live("allows distinct absolute targets to proceed independently", () => it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) { function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
return Layer.effect( return Layer.effect(
Environment.Service, FSUtil.Service,
Effect.gen(function* () { Effect.gen(function* () {
const environment = yield* Environment.Service const filesystem = yield* FSUtil.Service
return Environment.Service.of({ return FSUtil.Service.of({
...environment, ...filesystem,
files: { writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
...environment.files, writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
write: (target, content) => run(environment.files.write(target, content), target), writeFileString: (target, content, options) =>
}, run(filesystem.writeFileString(target, content, options), target),
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(Environment.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
} }
@@ -1,169 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
export interface EnvironmentHarness {
readonly files: Files
readonly root: string
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
readonly dispose?: Effect.Effect<void>
}
export const environmentConformance = <E>(
name: string,
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
const bytes = (value: string) => new TextEncoder().encode(value)
const text = (value: Uint8Array) => new TextDecoder().decode(value)
const suite = skip ? describe.skip : describe
suite(name, () => {
check("writes, stats, and reads a file with its info", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/hello.txt`
yield* harness.files.write(target, bytes("hello"))
const result = yield* harness.files.read(target)
expect(text(result.bytes)).toBe("hello")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(5)
expect(yield* harness.files.stat(target)).toEqual(result.info)
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
}),
)
check("reports the actual kind", (harness) =>
Effect.gen(function* () {
const directory = `${harness.root}/directory`
const file = `${harness.root}/file`
yield* harness.files.mkdir(directory)
yield* harness.files.write(file, bytes("data"))
const readError = yield* Effect.flip(harness.files.read(directory))
const listError = yield* Effect.flip(harness.files.list(file))
expect(readError).toBeInstanceOf(WrongKind)
expect((readError as WrongKind).actual).toBe("directory")
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("file")
}),
)
check("write creates parent directories", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/one/two/file`
yield* harness.files.write(target, bytes("nested"))
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
}),
)
check("reads byte ranges", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/range`
yield* harness.files.write(target, bytes("0123456789"))
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
}),
)
check("lists immediate entries with their kinds", (harness) =>
Effect.gen(function* () {
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
const entries = yield* harness.files.list(harness.root)
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
{ name: "directory", type: "directory" },
{ name: "file name", type: "file" },
])
}),
)
check("preserves symlink metadata while following symlinks for content", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
expect(
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "entry-link", type: "symlink" },
{ name: "file", type: "file" },
])
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
expect(fileError).toBeInstanceOf(WrongKind)
expect((fileError as WrongKind).actual).toBe("file")
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("follows symlinks when reading", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.symlink("target", `${harness.root}/file-link`)
yield* harness.symlink("directory", `${harness.root}/directory-link`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
const result = yield* harness.files.read(`${harness.root}/file-link`)
expect(text(result.bytes)).toBe("target content")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(bytes("target content").length)
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
expect(directoryError).toBeInstanceOf(WrongKind)
expect((directoryError as WrongKind).actual).toBe("directory")
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("moves files and removes trees idempotently", (harness) =>
Effect.gen(function* () {
const source = `${harness.root}/source/file`
const destination = `${harness.root}/destination`
yield* harness.files.write(source, bytes("moved"))
yield* harness.files.move(source, destination)
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
yield* harness.files.remove(`${harness.root}/source`)
yield* harness.files.remove(`${harness.root}/source`)
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
}),
)
})
}
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error" import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner" import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
it.effect("resumes each suspended Session at most once", () => it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () { Effect.gen(function* () {
const database = yield* Database.Service const database = yield* Database.Service
const bus = yield* Bus.Service
const first = Session.ID.make("ses_resume_first") const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second") const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() }) yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = [] const drained: string[] = []
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make() const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID))) const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service) const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service) const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
yield* restart.resumeSuspendedSessions yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true }) yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second]) expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
[first, second].map((sessionID) => ({
sessionID,
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
description: "Continuing after restart",
})),
)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false }) expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
yield* restart.resumeSuspendedSessions yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2) expect(drained.length).toBe(2)
expect(continued.length).toBe(2)
yield* Scope.close(scope, Exit.void) yield* Scope.close(scope, Exit.void)
}), }),
) )
-74
View File
@@ -1086,78 +1086,4 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* session.pending(sessionID)).toEqual([])
}), }),
) )
it.effect("cancels pending input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
}) })
+10 -164
View File
@@ -6,11 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill" import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystem } from "@opencode-ai/schema/filesystem" import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -25,15 +25,8 @@ const discovery = Layer.succeed(
}, },
}), }),
) )
const watcherLayer = Watcher.testLayer
const it = testEffect( const it = testEffect(
Layer.mergeAll( AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
) )
function write(directory: string, name: string, description: string) { function write(directory: string, name: string, description: string) {
@@ -60,24 +53,6 @@ function waitForSkillUpdate() {
}) })
} }
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => { describe("Skill", () => {
it.live("publishes updates when skill sources change", () => it.live("publishes updates when skill sources change", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -223,7 +198,7 @@ metadata:
), ),
) )
it.live("clears cached skills when sources reload", () => it.live("invalidates cached skills and publishes updates for watcher changes", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -235,155 +210,26 @@ metadata:
await write(tmp.path, "deploy", "Initial deploy") await write(tmp.path, "deploy", "Initial deploy")
}) })
const bus = yield* Bus.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
const file = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy")) yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload() expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* Effect.acquireUseRelease( yield* Effect.acquireUseRelease(
waitForSkillUpdate(), waitForSkillUpdate(),
({ deferred }) => ({ deferred }) =>
bus bus
.publish(FileSystem.Event.Changed, { file, event: "add" }) .publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber), ({ fiber }) => Fiber.interrupt(fiber),
) )
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")]) expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
yield* expectSubscription((input) => input.type === "directory" && input.path === first)
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
yield* expectSubscription((input) => input.type === "directory" && input.path === second)
}), }),
), ),
), ),
+33 -68
View File
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter" import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({ const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin", name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [ deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
}) })
const sessionID = Session.ID.make("ses_edit_tool_test") const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -80,28 +72,29 @@ const reset = () => {
formatFile = () => Effect.succeed(false) formatFile = () => Effect.succeed(false)
} }
const environment = Layer.effect( const filesystem = Layer.effect(
Environment.Service, FSUtil.Service,
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* Environment.Service const fs = yield* FSUtil.Service
return Environment.Service.of({ return FSUtil.Service.of({
...current, ...fs,
files: { readFile: (target) =>
...current.files, fs
read: (target, range) => .readFile(target)
current.files .pipe(
.read(target, range) Effect.tap((content) =>
.pipe( Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
Effect.tap((result) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
),
), ),
write: (target, content) => ),
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))), writeWithDirs: (target, content, mode) =>
}, Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
writeFile: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
writeFileString: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(Environment.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => { const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
editToolNode,
]),
[ [
[Environment.node, environment], [FSUtil.node, filesystem],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
@@ -472,7 +471,10 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) => withTool(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
expect( expect(
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })), yield* executeTool(
registry,
call({ path: "missing.ts", oldString: "before", newString: "after" }),
),
).toEqual({ ).toEqual({
status: "error", status: "error",
error: { type: "tool.execution", message: "File not found: missing.ts" }, error: { type: "tool.execution", message: "File not found: missing.ts" },
@@ -643,43 +645,6 @@ describe("EditTool", () => {
), ),
) )
it.live("serializes concurrent edit transactions", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("applies the edit when content changes after matching", () => it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
+42 -65
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect" import { Effect, Exit, Layer, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter" import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({ const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin", name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node], deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_patch_tool_test") const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -82,33 +81,48 @@ const reset = () => {
formatFile = () => Effect.succeed(false) formatFile = () => Effect.succeed(false)
} }
const environment = Layer.effect( const filesystem = Layer.effect(
Environment.Service, FSUtil.Service,
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* Environment.Service const fs = yield* FSUtil.Service
return Environment.Service.of({ return FSUtil.Service.of({
...current, ...fs,
files: { readFile: (target) =>
...current.files, Effect.sync(() => {
read: (target, range) => if (!editApproved) readsBeforeEditApproval++
Effect.sync(() => { }).pipe(Effect.andThen(fs.readFile(target))),
if (!editApproved) readsBeforeEditApproval++ remove: (target, options) => {
}).pipe(Effect.andThen(current.files.read(target, range))), if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
remove: (target) => { if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") return Effect.fail(
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) systemError({
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") })) _tag: "Unknown",
return current.files.remove(target) module: "FileSystem",
}, method: "remove",
write: (target, content) => { description: "forced remove failure",
if (failWriteTarget && path.basename(target) === failWriteTarget) pathOrDescriptor: target,
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") })) }),
return current.files.write(target, content) )
}, }
return fs.remove(target, options)
},
writeWithDirs: (target, content, mode) => {
if (failWriteTarget && path.basename(target) === failWriteTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "writeWithDirs",
description: "forced write failure",
pathOrDescriptor: target,
}),
)
}
return fs.writeWithDirs(target, content, mode)
}, },
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(Environment.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>( const withTool = <A, E, R>(
directory: string, directory: string,
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service) return yield* body(yield* Tool.Service)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [ AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[Environment.node, environment], [FSUtil.node, filesystem],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
), ),
) )
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
}),
)
it.live("returns file diffs for final formatted content", () => it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => { withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt") const target = path.join(directory, "formatted.txt")
+51 -161
View File
@@ -1,65 +1,66 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Environment } from "@opencode-ai/core/environment" import { Effect, FileSystem } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform" import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, FileSystem } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util"
import { ChildProcessSpawner } from "effect/unstable/process" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem]))) const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
const fixture = Effect.gen(function* () { const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem const files = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = yield* files.makeTempDirectoryScoped() const directory = yield* files.makeTempDirectoryScoped()
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory } return { fs, files, directory }
}) })
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadToolFileSystem", () => { describe("ReadToolFileSystem", () => {
it.effect("preserves the environment not-found error", () => it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, directory } = yield* fixture const { fs, directory } = yield* fixture
const file = path.join(directory, "missing.txt") const file = path.join(directory, "missing.txt")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip) const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
expect(error).toBeInstanceOf(Environment.NotFound) expect(error).toMatchObject({ _tag: "PlatformError" })
}), }),
) )
it.effect("returns a listing when read reports a directory", () => it.effect("fails when a file becomes the wrong path kind", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder") const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
expect(result).toMatchObject({ expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
type: "list-page", }),
entries: [ )
{ path: `folder${path.sep}`, type: "directory" },
{ path: "file.txt", type: "file" }, it.effect("fails with a typed filesystem error when directory listing fails", () =>
], Effect.gen(function* () {
}) const { fs, files, directory } = yield* fixture
const file = path.join(directory, "file.txt")
yield* files.writeFileString(file, "hello")
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
}), }),
) )
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () => it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat") const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt") const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3)) yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80)) yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip) const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt") const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError) expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat") expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads text despite a binary-associated extension", () => it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx") const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text") yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx") const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" }) expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}), }),
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
it.effect("lists unresolved symlinks, including broken and escaping links", () => it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () { Effect.gen(function* () {
if (process.platform === "win32") return if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped() const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder")) yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello") yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape"))) yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken"))) yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder") const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.type).toBe("list-page")
if (result.type !== "list-page") return
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([ expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" }, { path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" }, { path: "broken", type: "symlink" },
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
}), }),
) )
it.effect("reads a symlinked directory as a listing", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const target = path.join(directory, "target")
const link = path.join(directory, "link")
yield* files.makeDirectory(target)
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(target, link))
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: "file.txt", type: "file" }],
})
}),
)
it.effect("reports out-of-range pagination as a typed error", () => it.effect("reports out-of-range pagination as a typed error", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const file = path.join(directory, "short.txt") const file = path.join(directory, "short.txt")
yield* files.writeFileString(file, "one\n") yield* files.writeFileString(file, "one\n")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe( const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
Effect.flip,
)
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError) expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
expect(error.message).toBe("Offset 2 is out of range") expect(error.message).toBe("Offset 2 is out of range")
}), }),
) )
it.effect("pages text with one-based offsets", () => it.effect("stops reading after the requested page is complete", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const file = path.join(directory, "lines.txt") const prefix = new TextEncoder().encode("one\n")
yield* files.writeFileString(file, "one\r\ntwo\nthree") for (const [name, trailing] of [
["malformed.txt", 0x80],
["nul.txt", 0],
] as const) {
const file = path.join(directory, name)
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", { const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 }) expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}),
)
it.effect("truncates long lines", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "long.txt")
yield* files.writeFileString(file, "a".repeat(2_001))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
expect(result).toMatchObject({
type: "text-page",
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
truncated: false,
})
}),
)
it.effect("enforces line and byte budgets with continuation offsets", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const linesFile = path.join(directory, "many-lines.txt")
const bytesFile = path.join(directory, "many-bytes.txt")
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
const tracked = {
...environment,
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
} }
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
ReadToolFileSystem.MAX_READ_BYTES,
)
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
}),
)
it.effect("sorts and pages directory entries", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "z"))
yield* files.makeDirectory(path.join(directory, "a"))
yield* files.writeFileString(path.join(directory, "b.txt"), "")
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: `z${path.sep}`, type: "directory" }],
truncated: true,
next: 3,
})
}),
)
it.effect("stops checking for null bytes after the requested page", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "nul.txt")
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}),
)
it.effect("reads page two after fetching more than the first 256KB range", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "large.txt")
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
}), }),
) )
it.effect("preserves the media ingestion limit message", () => it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const file = path.join(directory, "oversized.png") const file = path.join(directory, "oversized.png")
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1) yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip) const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError) expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
expect(error.message).toBe( expect(error.message).toBe(
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads PDFs as bounded media", () => it.effect("reads PDFs as bounded media", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { environment, files, directory } = yield* fixture const { fs, files, directory } = yield* fixture
const file = path.join(directory, "document.pdf") const file = path.join(directory, "document.pdf")
yield* files.writeFileString(file, "%PDF-1.7\ncontent") yield* files.writeFileString(file, "%PDF-1.7\ncontent")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf") const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
expect(result).toMatchObject({ expect(result).toMatchObject({
type: "file", type: "file",
+48 -23
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test" import { beforeEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect, Exit, Layer, Stream } from "effect" import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config" import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media" import { ConfigMedia } from "@opencode-ai/schema/config/media"
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
const assertions: Permission.AssertInput[] = [] const assertions: Permission.AssertInput[] = []
const missingPath = "__missing_read_target__.txt" const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = path.join(process.cwd(), missingPath) const missingAbsolutePath = path.join(process.cwd(), missingPath)
const notFound = (target: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: target,
})
const readCalls: { const readCalls: {
input: AbsolutePath input: AbsolutePath
page: ReadToolFileSystem.PageInput page: ReadToolFileSystem.PageInput
}[] = [] }[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown let resolveFailure: unknown
let inspectFailure: ReadToolFileSystem.InspectError | undefined
let directoryEntries: string[] = [] let directoryEntries: string[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = { let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file", type: "file",
uri: "file:///README.md", uri: "file:///README.md",
name: "README.md", name: "README.md",
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
const reader = Layer.succeed( const reader = Layer.succeed(
ReadToolFileSystem.Service, ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({ ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure !== undefined
? Effect.die(resolveFailure)
: inspectFailure !== undefined
? Effect.fail(inspectFailure)
: Effect.succeed(resolvedType),
read: (input, _resource, page = {}) => { read: (input, _resource, page = {}) => {
readCalls.push({ input, page }) readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readFailure !== undefined) return Effect.fail(readFailure) if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult) return Effect.succeed(readResult)
}, },
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return listResult
}),
}), }),
) )
let allow = true let allow = true
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
FSUtil.Service.of({ FSUtil.Service.of({
...fs, ...fs,
readDirectory: () => Effect.succeed(directoryEntries), readDirectory: () => Effect.succeed(directoryEntries),
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: path,
}),
)
: Effect.succeed(path),
}), }),
), ),
), ),
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
beforeEach(() => { beforeEach(() => {
assertions.length = 0 assertions.length = 0
readCalls.length = 0 readCalls.length = 0
listCalls.length = 0
allow = true allow = true
resolvedType = "file"
resolveFailure = undefined resolveFailure = undefined
inspectFailure = undefined
directoryEntries = [] directoryEntries = []
readResult = { readResult = {
type: "file", type: "file",
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
mime: "text/plain", mime: "text/plain",
} }
readFailure = undefined readFailure = undefined
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
}) })
it.effect("registers, authorizes, and reads through the location filesystem", () => it.effect("registers, authorizes, and reads through the location filesystem", () =>
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () => it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () { Effect.gen(function* () {
readFailure = new Environment.NotFound({ path: missingAbsolutePath }) inspectFailure = notFound(missingAbsolutePath)
directoryEntries = [ directoryEntries = [
"__missing_read_target__.txt.bak", "__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt", "copy___missing_read_target__.txt",
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
}, },
}) })
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
expect(readCalls).toEqual([ expect(readCalls).toEqual([])
{
input: AbsolutePath.make(missingAbsolutePath),
page: { offset: undefined, limit: undefined },
},
])
}), }),
) )
it.effect("lists a bounded directory page through read", () => it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () { Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({ resolvedType = "directory"
listResult = new ReadToolFileSystem.ListPage({
type: "list-page", type: "list-page",
entries: [ entries: [
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }), FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
}) })
expect(result).toMatchObject({ expect(result).toMatchObject({
status: "completed", status: "completed",
output: { entries: readResult.entries, truncated: true, next: 4 }, output: { entries: listResult.entries, truncated: true, next: 4 },
}) })
if (result.status !== "completed") return if (result.status !== "completed") return
expect(result.metadata).toEqual({ truncated: true }) expect(result.metadata).toEqual({ truncated: true })
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
}, },
]) ])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(readCalls).toEqual([ expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
])
}), }),
) )
it.effect("does not list a directory when permission is denied", () => it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () { Effect.gen(function* () {
allow = false allow = false
resolvedType = "directory"
const registry = yield* Tool.Service const registry = yield* Tool.Service
expect( expect(
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}), }),
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } }) ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([]) expect(listCalls).toEqual([])
}), }),
) )
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
), ),
).toBe(true) ).toBe(true)
expect(readCalls).toEqual([ expect(readCalls).toEqual([])
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
}), }),
) )
+19 -6
View File
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({ const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin", name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node], deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
}) })
const grepToolNode = makeLocationNode({ const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin", name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node], deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_search_tool_test") const sessionID = Session.ID.make("ses_search_tool_test")
@@ -179,7 +186,9 @@ describe("search tools", () => {
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => (tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe( Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))), Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) => Effect.tap((result) =>
Effect.sync(() => { Effect.sync(() => {
expect(result).toMatchObject({ expect(result).toMatchObject({
@@ -288,7 +297,9 @@ describe("search tools", () => {
(tmp) => (tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe( Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen( Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))), withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
), ),
Effect.tap((result) => Effect.tap((result) =>
Effect.sync(() => { Effect.sync(() => {
@@ -320,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => { Effect.sync(() => {
expect(result.status).toBe("completed") expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"]) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")]) expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}), }),
), ),
) )
+1 -2
View File
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
const content = settled.content?.[0] const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content") if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text).not.toContain("one") expect(content.text).not.toContain("one")
// Windows shells emit CRLF; the assertion targets line limits, not line endings. expect(content.text).toStartWith("two\nthree")
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
expect(content.text).toContain("output truncated; full output saved to:") expect(content.text).toContain("output truncated; full output saved to:")
}) })
}, },
+11 -14
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter" import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({ const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin", name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node], deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_write_tool_test") const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,20 +68,17 @@ const reset = () => {
denyAction = undefined denyAction = undefined
} }
const environment = Layer.effect( const filesystem = Layer.effect(
Environment.Service, FSUtil.Service,
Effect.gen(function* () { Effect.gen(function* () {
const current = yield* Environment.Service const fs = yield* FSUtil.Service
return Environment.Service.of({ return FSUtil.Service.of({
...current, ...fs,
files: { writeWithDirs: (target, content, mode) =>
...current.files, Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(Environment.node))) ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => { const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[ [
[Environment.node, environment], [FSUtil.node, filesystem],
[Location.node, activeLocation], [Location.node, activeLocation],
[Formatter.node, formatter], [Formatter.node, formatter],
[Permission.node, permission], [Permission.node, permission],
-39
View File
@@ -522,45 +522,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}), }),
), ),
) )
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
.add( .add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", { HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID }, params: { sessionID: Session.ID },
+7 -35
View File
@@ -152,15 +152,13 @@ export const Forked = Event.durable({
}) })
export type Forked = typeof Forked.Type export type Forked = typeof Forked.Type
const InputRef = {
...Base,
inputID: SessionMessage.ID,
}
export const InputPromoted = Event.durable({ export const InputPromoted = Event.durable({
type: "session.input.promoted", type: "session.input.promoted",
...options, ...options,
schema: InputRef, schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
}) })
export type InputPromoted = typeof InputPromoted.Type export type InputPromoted = typeof InputPromoted.Type
@@ -168,33 +166,13 @@ export const InputAdmitted = Event.durable({
type: "session.input.admitted", type: "session.input.admitted",
...options, ...options,
schema: { schema: {
...InputRef, ...Base,
inputID: SessionMessage.ID,
input: SessionPending.Message, input: SessionPending.Message,
}, },
}) })
export type InputAdmitted = typeof InputAdmitted.Type export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: InputRef,
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: InputRef,
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: InputRef,
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution { export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base }) export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type export type Started = typeof Started.Type
@@ -602,9 +580,6 @@ export const Definitions = Event.inventory(
Forked, Forked,
InputPromoted, InputPromoted,
InputAdmitted, InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started, Execution.Started,
Execution.Succeeded, Execution.Succeeded,
Execution.Failed, Execution.Failed,
@@ -646,16 +621,13 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"), ...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded, UsageRecorded,
) )
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type")) .pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" }) .annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"), Schema.toTaggedUnion("type"),
) )
export type Event = typeof All.Type export type Event = typeof All.Type
@@ -84,9 +84,6 @@ describe("public event manifest", () => {
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",
"session.input.admitted.1", "session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1", "session.execution.started.1",
"session.execution.succeeded.1", "session.execution.succeeded.1",
"session.execution.failed.1", "session.execution.failed.1",
-43
View File
@@ -26,22 +26,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* Session.Service const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service const transfer = yield* SessionTransfer.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
return handlers return handlers
.handle( .handle(
@@ -677,33 +661,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
} }
}), }),
) )
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.handle( .handle(
"session.instructions.entry.list", "session.instructions.entry.list",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
@@ -462,7 +462,6 @@ function newLayout() {
function webSearchProviderLabel(provider: unknown) { function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
+7 -49
View File
@@ -53,14 +53,12 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render" import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
visible?: boolean visible?: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element hint?: JSX.Element
right?: JSX.Element right?: JSX.Element
@@ -359,20 +357,6 @@ export function Prompt(props: PromptProps) {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{ {
title: "Remove editor context", title: "Remove editor context",
name: "prompt.editor_context.clear", name: "prompt.editor_context.clear",
@@ -531,11 +515,6 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(), commands: promptCommands(),
})) }))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
bindings: [ bindings: [
"prompt.submit", "prompt.submit",
@@ -921,7 +900,7 @@ export function Prompt(props: PromptProps) {
}) })
let submitting = false let submitting = false
async function submit(delivery: SessionPending.Delivery = "steer") { async function submit() {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard, // input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call // a second call slips past the empty-input check before the first call
@@ -931,13 +910,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false if (submitting) return false
submitting = true submitting = true
try { try {
return await submitInner(delivery) return await submitInner()
} finally { } finally {
submitting = false submitting = false
} }
} }
async function submitInner(delivery: SessionPending.Delivery) { async function submitInner() {
// IME: double-defer may fire before onContentChange flushes the last // IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read // composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads. // plainText directly and sync before any downstream reads.
@@ -948,25 +927,14 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false if (props.disabled) return false
if (move.creating()) return false if (move.creating()) return false
if (auto()?.visible) return false if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim() const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit() void exit()
return true return true
} }
const slash = argumentSlash(store.prompt.text, keymapCommands()) const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) { if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt() clearPrompt()
await slash.command.run(slash.input) await slash.command.run(slash.input)
return true return true
@@ -990,16 +958,6 @@ export function Prompt(props: PromptProps) {
const isCommand = const isCommand =
slashHead !== undefined && slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name) (data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
if (delivery === "queue" && isSkill) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (delivery === "queue" && pendingEditorSelection) {
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
const selection = local.model.selection() const selection = local.model.selection()
@@ -1058,6 +1016,8 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (store.mode === "shell") { if (store.mode === "shell") {
move.startSubmit() move.startSubmit()
@@ -1080,7 +1040,6 @@ export function Prompt(props: PromptProps) {
model, model,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.catch((error) => { .catch((error) => {
cancelCommit() cancelCommit()
@@ -1090,7 +1049,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit() move.startSubmit()
void client.api.session.skill({ void client.api.session.skill({
sessionID, sessionID,
skill: slashHead.name, skill: slashHead!.name,
}) })
} else { } else {
move.startSubmit() move.startSubmit()
@@ -1146,7 +1105,6 @@ export function Prompt(props: PromptProps) {
text: inputText, text: inputText,
files: store.prompt.files, files: store.prompt.files,
agents: store.prompt.agents, agents: store.prompt.agents,
delivery,
}) })
.then( .then(
() => undefined, () => undefined,
+2 -6
View File
@@ -103,8 +103,7 @@ export const Definitions = {
session_interrupt: keybind("escape", "Interrupt current session"), session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background blocking session tools"), session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"), session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View queued prompts"), session_queued_prompts: keybind("<leader>q", "View pending work"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_child_first: keybind("down", "Toggle subagent picker"), session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"), session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"), session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
@@ -162,7 +161,6 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"), display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"), prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"), prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"), prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"), prompt_stash: keybind("none", "Stash prompt"),
@@ -172,7 +170,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"), input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"), input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"), input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"), input_move_up: keybind("up", "Move cursor up in input"),
@@ -307,7 +305,6 @@ export const CommandMap = {
session_background: "session.background", session_background: "session.background",
session_compact: "session.compact", session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts", session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first", session_child_first: "session.child.first",
session_parent: "session.parent", session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle", session_pin_toggle: "session.pin.toggle",
@@ -362,7 +359,6 @@ export const CommandMap = {
messages_redo: "session.redo", messages_redo: "session.redo",
display_thinking: "session.toggle.thinking", display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit", prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear", prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills", prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash", prompt_stash: "prompt.stash",
+15 -49
View File
@@ -38,7 +38,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useClient } from "./client" import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display" import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -171,20 +170,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) { function removePending(sessionID: string, inputID?: string) {
if (!inputID) return if (!inputID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inputID)) setStore(
setStore( "session",
"session", "pending",
"pending", sessionID,
sessionID, (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID), )
)
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
} }
function removePermission(sessionID: string, requestID: string) { function removePermission(sessionID: string, requestID: string) {
@@ -198,13 +189,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
} }
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
const message = { const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) { update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore( setStore(
@@ -251,12 +235,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
) )
}, },
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
} }
function index(sessionID: string) { function index(sessionID: string) {
@@ -438,36 +416,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
} }
break break
case "session.input.promoted": { case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID) const position = index.get(event.data.inputID)
if (position === undefined) return if (position === undefined) return
const existing = draft[position] const existing = draft[position]
if (!existing || !admitted) return if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
existing.time.created = event.created existing.time.created = event.created
draft.splice(position, 1) draft.splice(position, 1)
draft.push(existing) draft.push(existing)
message.reindex(draft, index, position) index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
}) })
break setStore(
} "session",
case "session.input.steered": "input",
updatePending(event.data.sessionID, event.data.inputID, "steer") event.data.sessionID,
break (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
case "session.input.queued": )
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
break break
} }
case "session.input.admitted": case "session.input.admitted":
+2 -20
View File
@@ -27,7 +27,6 @@ export interface Storage {
* JSON-serializable. * JSON-serializable.
*/ */
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value> memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
flush(): Promise<void>
} }
function clone<Value extends object>(value: Value) { function clone<Value extends object>(value: Value) {
@@ -47,7 +46,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) { function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>() const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>() const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
const directory = path.join(root, segment(channel), "tui") const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks") const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true }) mkdirSync(directory, { recursive: true })
@@ -68,8 +66,8 @@ function createStorage(root: string, channel: string) {
const [store, setStore] = createStore(load()) const [store, setStore] = createStore(load())
const merge = (next: Value) => reconcile(next, { key: options.key }) const merge = (next: Value) => reconcile(next, { key: options.key })
const reload = () => batch(() => setStore(merge(load()))) const reload = () => batch(() => setStore(merge(load())))
const update = (mutation: (draft: Value) => void) => { const update = (mutation: (draft: Value) => void) =>
const operation = Flock.withLock( Flock.withLock(
file, file,
async () => { async () => {
const draft = load() const draft = load()
@@ -80,13 +78,6 @@ function createStorage(root: string, channel: string) {
}, },
{ dir: locks }, { dir: locks },
) )
pending.add(operation)
operation.then(
() => pending.delete(operation),
() => pending.delete(operation),
)
return operation
}
const entry = [store, update] as const const entry = [store, update] as const
entries.set(file, { value: entry as Entry<object>, reload }) entries.set(file, { value: entry as Entry<object>, reload })
return entry return entry
@@ -99,15 +90,6 @@ function createStorage(root: string, channel: string) {
memories.set(key, entry as MemoryEntry<object>) memories.set(key, entry as MemoryEntry<object>)
return entry return entry
}, },
async flush() {
const failures: unknown[] = []
while (pending.size > 0) {
const results = await Promise.allSettled(pending)
failures.push(...results.filter((result) => result.status === "rejected").map((result) => result.reason))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, "Storage writes failed")
},
} }
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload())) const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
+6 -33
View File
@@ -3,9 +3,7 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid" import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js" import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { import type {
FooterQueuedPrompt, FooterQueuedPrompt,
@@ -58,10 +56,6 @@ type SkillEntry = PanelEntry & {
name: string name: string
} }
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & { type SubagentEntry = PanelEntry & {
sessionID: string sessionID: string
current: boolean current: boolean
@@ -448,7 +442,7 @@ export function RunCommandMenuBody(props: {
{ {
action: "queued" as const, action: "queued" as const,
category: "Agent", category: "Agent",
display: "View queued prompts", display: "View pending work",
footer: `${props.queued().length} pending`, footer: `${props.queued().length} pending`,
keywords: props keywords: props
.queued() .queued()
@@ -843,48 +837,28 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]> prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void onRows?: (rows: number) => void
mono?: boolean mono?: boolean
}) { }) {
const entries = createMemo<QueuedPromptEntry[]>(() => const entries = createMemo(() =>
props.prompts().map((prompt) => ({ props.prompts().map((prompt) => ({
category: "", category: "",
display: prompt.prompt.text.replaceAll("\n", " "), display: prompt.prompt.text.replaceAll("\n", " "),
footer: "queued", footer: prompt.delivery,
keywords: prompt.prompt.text, keywords: prompt.prompt.text,
prompt,
})), })),
) )
const controller = createSearchablePanelController({ const controller = createSearchablePanelController({
entries, entries,
limit: SUBAGENT_LIST_ROWS, limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose, onClose: props.onClose,
onSelect: (item) => props.onSteer(item.prompt), onSelect: props.onClose,
onRows: props.onRows, onRows: props.onRows,
}) })
const shortcuts = Keymap.useShortcuts()
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.delete",
title: "Delete queued prompt",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onDelete(item.prompt)
},
},
],
}))
return ( return (
<PanelShell <PanelShell
title="Queued prompts" title="Pending work"
query={controller.query()} query={controller.query()}
count={controller.items().length} count={controller.items().length}
total={entries().length} total={entries().length}
@@ -892,7 +866,6 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme} theme={props.theme}
inputRef={controller.inputRef} inputRef={controller.inputRef}
onQuery={controller.setQuery} onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono} mono={props.mono}
> >
<RunFooterMenu <RunFooterMenu
@@ -902,7 +875,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset} offset={controller.menu.offset}
rows={controller.menu.rows} rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS} limit={SUBAGENT_LIST_ROWS}
empty="No queued prompts" empty="No pending work"
border={false} border={false}
paddingLeft={panelPad(props.mono)} paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)} paddingRight={panelPad(props.mono)}
+13 -62
View File
@@ -19,7 +19,6 @@ import {
displayCharAt, displayCharAt,
displaySlice, displaySlice,
isExitCommand, isExitCommand,
isCompactCommand,
mentionTriggerIndex, mentionTriggerIndex,
isNewCommand, isNewCommand,
movePromptHistory, movePromptHistory,
@@ -32,16 +31,7 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono" import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme" import type { RunFooterTheme } from "./theme"
import type { import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunDelivery,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1 const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -82,8 +72,6 @@ type PromptInput = {
theme: Accessor<RunFooterTheme> theme: Accessor<RunFooterTheme>
mono: Accessor<boolean> mono: Accessor<boolean>
history?: Accessor<RunPrompt[]> history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
@@ -992,18 +980,8 @@ export function createPromptState(input: PromptInput): PromptState {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(), enabled: input.prompt() && !visible(),
commands: [ commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{ {
id: "prompt.editor", id: "prompt.editor",
title: "Open editor", title: "Open editor",
@@ -1138,8 +1116,7 @@ export function createPromptState(input: PromptInput): PromptState {
} }
} }
let submitting = false const submitPrompt = (next: RunPrompt) => {
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
if (!area || area.isDestroyed) { if (!area || area.isDestroyed) {
draft = promptCopy(next) draft = promptCopy(next)
} }
@@ -1153,34 +1130,12 @@ export function createPromptState(input: PromptInput): PromptState {
hide() hide()
} }
if (submitting) return
if (!next.text.trim()) { if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
submitting = false
})
return
}
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored") input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return return
} }
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command) const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" ||
command?.source === "skill" ||
isNewCommand(next.text) ||
isCompactCommand(next.text) ||
isExitCommand(next.text) ||
next.text.trim().toLowerCase() === "/settings")
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) { if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit() input.onExit()
return return
@@ -1202,28 +1157,24 @@ export function createPromptState(input: PromptInput): PromptState {
} }
const submit = command const submit = command
? { ...next, command, delivery } ? { ...next, command }
: parsed?.type === "command" : parsed?.type === "command"
? { ...next, command: parsed.command, delivery } ? { ...next, command: parsed.command }
: { ...next, delivery } : next
const shellMode = next.mode === "shell" const shellMode = next.mode === "shell"
submitting = true
resetDraft() resetDraft()
queueMicrotask(async () => { queueMicrotask(async () => {
try { if (await input.onSubmit(submit)) {
if (await input.onSubmit(submit)) { push(next)
push(next) if (shellMode) {
if (shellMode) { setShellMode(false)
setShellMode(false) draft = emptyPrompt(false)
draft = emptyPrompt(false)
}
return
} }
restore(next) return
} finally {
submitting = false
} }
restore(next)
}) })
} }
-3
View File
@@ -51,7 +51,6 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -97,7 +96,6 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
@@ -345,7 +343,6 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle, onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt, onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground, onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen, onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear, onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit, onExitRequest: footer.handleExit,
+11 -41
View File
@@ -34,8 +34,6 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared" import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono" import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width" import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import { createSingleFlight } from "../util/single-flight"
import type { import type {
FooterPromptRoute, FooterPromptRoute,
@@ -48,7 +46,6 @@ import type {
MiniSettingChange, MiniSettingChange,
MiniSettings, MiniSettings,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunCommand, RunCommand,
RunInput, RunInput,
@@ -95,14 +92,13 @@ type RunFooterViewProps = {
mono: boolean mono: boolean
miniSettings: () => MiniSettings miniSettings: () => MiniSettings
history?: () => RunPrompt[] history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean> onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void> onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void> onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void onCycle: () => void
onInterrupt: () => boolean onInterrupt: () => boolean
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined> onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void onInputClear: () => void
onExitRequest?: () => boolean onExitRequest?: () => boolean
@@ -136,7 +132,6 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" }) const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS) const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? []) const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer") const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu") const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -234,7 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`] const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current) if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage()) if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (queue().length > 0) details.push(`${queue().length} queued`) if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`) if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ") return details.join(props.mono ? " - " : " · ")
}) })
@@ -314,7 +309,7 @@ export function RunFooterView(props: RunFooterViewProps) {
} }
const openQueuedMenu = () => { const openQueuedMenu = () => {
if (queue().length === 0) return if (queuedPrompts().length === 0) return
setRoute({ type: "queued-menu" }) setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined) props.onSubagentSelect?.(undefined)
} }
@@ -323,22 +318,6 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" }) setRoute({ type: "composer" })
} }
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inputID, async () => {
const error = await run(action, inputID).then(
() => undefined,
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
})
return result ?? false
}
const openTab = (sessionID: string) => { const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID }) setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID) props.onSubagentSelect?.(sessionID)
@@ -378,8 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
theme, theme,
mono: () => props.mono, mono: () => props.mono,
history: props.history, history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit, onSubmit: props.onSubmit,
onCycle: props.onCycle, onCycle: props.onCycle,
onInterrupt: props.onInterrupt, onInterrupt: props.onInterrupt,
@@ -474,12 +451,13 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) { if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" }) items.push({ key: backgroundShortcut(), label: "background" })
} }
if (queue().length > 0 && queuedShortcut()) { if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` }) items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
} }
if (activeTabs().length > 0 && subagentShortcut()) { if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" }) items.push({ key: subagentShortcut(), label: "subagents" })
} }
return items return items
}) })
const commandHint = createMemo(() => { const commandHint = createMemo(() => {
@@ -590,11 +568,11 @@ export function RunFooterView(props: RunFooterViewProps) {
})) }))
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0, enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [ commands: [
{ {
id: "session.queued_prompts", id: "session.queued_prompts",
title: "View queued prompts", title: "View pending work",
group: "Session", group: "Session",
run: openQueuedMenu, run: openQueuedMenu,
}, },
@@ -652,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}) })
createEffect(() => { createEffect(() => {
if (route().type !== "queued-menu" || queue().length > 0) return if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
closePanel() closePanel()
}) })
@@ -756,16 +734,8 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}> <Match when={selectingQueued()}>
<RunQueuedPromptSelectBody <RunQueuedPromptSelectBody
theme={theme} theme={theme}
prompts={queue} prompts={queuedPrompts}
onClose={closePanel} onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows} onRows={setSubagentMenuRows}
mono={props.mono} mono={props.mono}
/> />
@@ -775,7 +745,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme} theme={theme}
commands={props.commands} commands={props.commands}
subagents={tabs} subagents={tabs}
queued={queue} queued={queuedPrompts}
variants={props.variants} variants={props.variants}
variantCycle={variantCycle()} variantCycle={variantCycle()}
onClose={closePanel} onClose={closePanel}
@@ -22,7 +22,6 @@ import type {
MiniSettings, MiniSettings,
MiniHost, MiniHost,
PermissionReply, PermissionReply,
QueuedPromptAction,
RunAgent, RunAgent,
RunInput, RunInput,
RunPrompt, RunPrompt,
@@ -71,7 +70,6 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void onInterrupt?: () => void
onBackground?: () => void onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void onSubagentInterrupt?: (sessionID: string) => void
} }
@@ -245,7 +243,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect, onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt, onInterrupt: input.onInterrupt,
onBackground: input.onBackground, onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => { onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) { if (closed || renderer.isDestroyed) {
return return
+6 -7
View File
@@ -11,7 +11,7 @@
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types" import type { FooterApi, FooterEvent, RunPrompt } from "./types"
type Trace = { type Trace = {
write(type: string, data?: unknown): void write(type: string, data?: unknown): void
@@ -21,11 +21,11 @@ export type QueueInput = {
footer: FooterApi footer: FooterApi
initialInput?: string initialInput?: string
trace?: Trace trace?: Trace
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void> onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void> onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void> onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void> admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
settle: () => Promise<void> settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void> run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
} }
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit) input.trace?.write("ui.commit", commit)
input.footer.append(commit) input.footer.append(commit)
} }
input.onSend?.(sent, sent.delivery ?? "steer") input.onSend?.(sent, "steer")
if (state.closed) { if (state.closed) {
break break
@@ -276,11 +276,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() } const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission const admission = state.admission
admissionVersion += 1 admissionVersion += 1
const delivery = prompt.delivery ?? "queue" input.onSend?.(sent, "queue")
input.onSend?.(sent, delivery)
admissions = admissions admissions = admissions
.then(() => admission) .then(() => admission)
.then(() => input.admit(sent, delivery, admissionController.signal)) .then(() => input.admit(sent, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error))) .catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return return
} }
+11 -23
View File
@@ -390,15 +390,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
log?.write("send.background", { sessionID: state.sessionID }) log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {}) void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
}, },
onQueuedPromptAction: async (action, inputID) => {
if (!state.sessionID) return
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inputID })
if (action === "steer") {
await state.sdk.session.pending.steer({ sessionID: state.sessionID, inputID })
return
}
await state.sdk.session.pending.cancel({ sessionID: state.sessionID, inputID })
},
onSubagentInterrupt: (sessionID) => { onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID }) log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {}) void state.sdk.session.interrupt({ sessionID }).catch(() => {})
@@ -901,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
trace: log, trace: log,
onSend: (prompt, delivery) => { onSend: (prompt, delivery) => {
state.shown = true state.shown = true
state.history.push({ ...prompt, delivery: undefined }) state.history.push(prompt)
if (prompt.mode !== "shell" && delivery === "steer") { if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({ rememberLocal({
kind: "user", kind: "user",
@@ -912,21 +903,18 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}) })
} }
}, },
admit: async (prompt, delivery, signal) => { admit: async (prompt, signal) => {
await state.switching?.catch(() => {}) await state.switching?.catch(() => {})
const next = await ensureStream() const next = await ensureStream()
await next.handle.admitPromptTurn( await next.handle.queuePromptTurn({
{ agent: state.agent,
agent: state.agent, model: state.model,
model: state.model, variant: state.activeVariant,
variant: state.activeVariant, prompt,
prompt, files: input.files,
files: input.files, includeFiles: false,
includeFiles: false, signal,
signal, })
},
delivery,
)
}, },
onAdmissionError: renderPromptError, onAdmissionError: renderPromptError,
onCompact: async () => { onCompact: async () => {
@@ -653,10 +653,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
return return
} }
if (event.type === "session.input.cancelled") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
touch(child, event.created) touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
+7 -48
View File
@@ -26,7 +26,6 @@ import type {
FooterQueuedPrompt, FooterQueuedPrompt,
RunFilePart, RunFilePart,
RunInput, RunInput,
RunDelivery,
RunPrompt, RunPrompt,
RunPromptPart, RunPromptPart,
StreamCommit, StreamCommit,
@@ -72,7 +71,7 @@ export type SessionResizeReplayInput = {
export type SessionTransport = { export type SessionTransport = {
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void> runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
admitPromptTurn(input: SessionTurnInput, delivery: RunDelivery): Promise<void> queuePromptTurn(input: SessionTurnInput): Promise<void>
waitForIdle(): Promise<void> waitForIdle(): Promise<void>
interruptActiveTurn(): Promise<void> interruptActiveTurn(): Promise<void>
selectSubagent(sessionID: string | undefined): void selectSubagent(sessionID: string | undefined): void
@@ -516,12 +515,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
) )
} }
let syncedPending: string[] | undefined
const syncPending = () => { const syncPending = () => {
const prompts = [...state.pending.values()].filter((item) => item.delivery === "queue") const prompts = [...state.pending.values()]
const ids = prompts.map((item) => item.messageID)
if (syncedPending?.length === ids.length && syncedPending.every((id, index) => id === ids[index])) return
syncedPending = ids
input.trace?.write("ui.patch", { pending: prompts.length }) input.trace?.write("ui.patch", { pending: prompts.length })
input.footer.event({ type: "queued.prompts", prompts }) input.footer.event({ type: "queued.prompts", prompts })
} }
@@ -939,36 +934,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([], { phase: "running", status: "waiting for assistant" }) write([], { phase: "running", status: "waiting for assistant" })
return return
} }
if (event.type === "session.input.steered") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "steer" })
syncPending()
if (state.messageIDs.has(event.data.inputID)) return
state.messageIDs.add(event.data.inputID)
write([
{
kind: "user",
source: "system",
text: pending.prompt.text,
phase: "start",
messageID: event.data.inputID,
},
])
return
}
if (event.type === "session.input.queued") {
const pending = state.pending.get(event.data.inputID)
if (!pending) return
state.pending.set(event.data.inputID, { ...pending, delivery: "queue" })
syncPending()
return
}
if (event.type === "session.input.cancelled") {
state.admitted.delete(event.data.inputID)
if (state.pending.delete(event.data.inputID)) syncPending()
return
}
if (event.type === "session.step.started") { if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id } state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" }) write([], { phase: "running", status: "assistant responding" })
@@ -1612,7 +1577,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
let queuedResizeReplay: SessionResizeReplayInput | undefined let queuedResizeReplay: SessionResizeReplayInput | undefined
let closing: Promise<void> | undefined let closing: Promise<void> | undefined
const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: RunDelivery) => { const admitPrompt = async (next: SessionTurnInput, client: OpenCodeClient, delivery: "steer" | "queue") => {
const messageID = next.prompt.messageID const messageID = next.prompt.messageID
if (!messageID) throw new Error("Prompt message ID is required") if (!messageID) throw new Error("Prompt message ID is required")
const command = next.prompt.command const command = next.prompt.command
@@ -1678,20 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
return { return {
async admitPromptTurn(next, delivery) { async queuePromptTurn(next) {
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill") if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
throw new Error("This prompt cannot be queued") throw new Error("This prompt cannot be queued")
if (!state.connected) throw new Error("Event stream is reconnecting") if (!state.connected) throw new Error("Event stream is reconnecting")
const client = sdk const client = sdk
if (next.agent) if (next.agent)
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal }) await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
if (!next.prompt.command) { mergePending(await admitPrompt(next, client, "queue"))
const selected = await resolveSelectedModel(input, client, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
}
mergePending(await admitPrompt(next, client, delivery))
settlementClient = client settlementClient = client
}, },
async waitForIdle() { async waitForIdle() {
@@ -1729,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return return
} }
if (command) { if (command) {
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
return return
} }
@@ -1741,7 +1700,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (selected) if (selected)
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal }) await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted) await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
}, },
async interruptActiveTurn() { async interruptActiveTurn() {
// A running shell holds no drain, so session.interrupt cannot reach it; // A running shell holds no drain, so session.interrupt cannot reach it;
+1 -7
View File
@@ -23,7 +23,6 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import type { Config } from "../config" import type { Config } from "../config"
import type { CliRenderer } from "@opentui/core" import type { CliRenderer } from "@opentui/core"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type RunFilePart = { export type RunFilePart = {
type: "file" type: "file"
@@ -72,13 +71,10 @@ export type RunProvider = {
models: Record<string, RunProviderModel> models: Record<string, RunProviderModel>
} }
export type RunDelivery = SessionPending.Delivery
export type RunPrompt = { export type RunPrompt = {
messageID?: string messageID?: string
text: string text: string
parts: RunPromptPart[] parts: RunPromptPart[]
delivery?: RunDelivery
mode?: "shell" mode?: "shell"
command?: { command?: {
name: string name: string
@@ -91,11 +87,9 @@ export type RunPrompt = {
export type FooterQueuedPrompt = { export type FooterQueuedPrompt = {
messageID: string messageID: string
prompt: RunPrompt prompt: RunPrompt
delivery: RunDelivery delivery: "steer" | "queue"
} }
export type QueuedPromptAction = "steer" | "cancel"
export type RunAgent = { export type RunAgent = {
id: string id: string
name: string name: string
+7 -136
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner" import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { PatchDiff } from "../../component/patch-diff" import { PatchDiff } from "../../component/patch-diff"
import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from "../../context/theme" import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt" import { Prompt, type PromptRef } from "../../component/prompt"
import type { import type {
@@ -52,7 +52,6 @@ import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor" import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork" import { DialogFork } from "./dialog-fork"
@@ -98,9 +97,6 @@ import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args" import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useSessionTabs } from "../../context/session-tabs" import { useSessionTabs } from "../../context/session-tabs"
import { createSingleFlight } from "../../util/single-flight"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { generateThinkingSyntax } from "./thinking-syntax"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -113,7 +109,6 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40 const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60 const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120 const TRANSCRIPT_BACKFILL_DELAY = 120
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{ const context = createContext<{
width: number width: number
@@ -125,8 +120,6 @@ const context = createContext<{
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
models: () => ModelInfo[] models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"] config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inputID: string) => Promise<boolean>
pendingDelivery: (inputID: string) => SessionPending.Delivery | undefined
}>() }>()
function use() { function use() {
@@ -182,13 +175,6 @@ export function Session() {
.flatMap((sessionID) => data.session.form.list(sessionID) ?? []) .flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
.concat(global) .concat(global)
}) })
const pendingUsers = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [])),
)
const [composer, setComposer] = createStore({ const [composer, setComposer] = createStore({
open: false, open: false,
tab: undefined as string | undefined, tab: undefined as string | undefined,
@@ -383,55 +369,6 @@ export function Session() {
}) })
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inputID: string) => {
const result = await runPendingAction(inputID, async () => {
const request =
action === "steer"
? client.api.session.pending.steer({ sessionID: route.sessionID, inputID })
: action === "queue"
? client.api.session.pending.queue({ sessionID: route.sessionID, inputID })
: client.api.session.pending.cancel({ sessionID: route.sessionID, inputID })
const error = await request.then(
() => undefined,
(error) => error,
)
if (!error) return true
const label = action === "cancel" ? "delete" : action
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
})
return result ?? false
}
const openQueuedPrompts = () =>
dialog.replace(() => (
<DialogSelect
title="Queued prompts"
options={queuedPrompts().map((prompt, index) => ({
title: prompt.text,
value: prompt.id,
footer: `${index + 1} of ${queuedPrompts().length}`,
}))}
onSelect={(option) => {
void mutatePending("steer", option.value).then((steered) => {
if (steered) dialog.clear()
})
}}
actions={[
{
command: "queued_prompt.delete",
title: "delete",
onTrigger: (option) => {
const last = queuedPrompts().length === 1
void mutatePending("cancel", option.value).then((cancelled) => {
if (cancelled && last) dialog.clear()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>
))
const unavailable = (feature: string) => { const unavailable = (feature: string) => {
toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 }) toast.show({ message: `${feature} is not implemented for V2 sessions yet`, variant: "error", duration: 5000 })
dialog.clear() dialog.clear()
@@ -934,13 +871,6 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
{ {
title: "Go to parent session", title: "Go to parent session",
id: "session.parent", id: "session.parent",
@@ -1012,8 +942,6 @@ export function Session() {
diffWrapMode, diffWrapMode,
models, models,
config, config,
mutatePending,
pendingDelivery: (inputID) => pendingDeliveries().get(inputID),
}} }}
> >
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
@@ -1069,9 +997,6 @@ export function Session() {
</Show> </Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" /> <PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
@@ -1107,11 +1032,6 @@ export function Session() {
onSubmit={() => { onSubmit={() => {
toBottom() toBottom()
}} }}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID} sessionID={route.sessionID}
/> />
</Match> </Match>
@@ -1433,7 +1353,6 @@ function SessionReasoningGroupView(props: {
const ctx = use() const ctx = use()
const theme = useTheme() const theme = useTheme()
const { currentSyntax: syntax } = useThemes() const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const renderer = useRenderer() const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
@@ -1529,7 +1448,7 @@ function SessionReasoningGroupView(props: {
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined} streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={thinkingSyntax()} syntaxStyle={syntax()}
content={content()} content={content()}
conceal={ctx.markdownMode() === "rendered"} conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued} fg={theme.text.subdued}
@@ -1894,7 +1813,6 @@ function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "she
return ( return (
<box <box
width="100%"
border={["left"]} border={["left"]}
paddingTop={1} paddingTop={1}
paddingBottom={1} paddingBottom={1}
@@ -1922,20 +1840,18 @@ function UserMessage(props: { message: SessionMessageUser }) {
const mode = themes.mode const mode = themes.mode
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build")) const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
const delivery = createMemo(() => ctx.pendingDelivery(props.message.id)) const queued = createMemo(
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
)
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const updatePendingSteer = async (action: "queue" | "cancel") => {
if (await ctx.mutatePending(action, props.message.id)) dialog.clear()
}
return ( return (
<Show when={props.message.text.trim() || files().length}> <Show when={props.message.text.trim() || files().length}>
<box <box
border={["left"]} border={["left"]}
borderColor={delivery() ? theme.border.default : color()} borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars} customBorderChars={SplitBorder.customBorderChars}
> >
<box <box
@@ -1947,21 +1863,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
}} }}
onMouseUp={() => { onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return if (renderer.getSelection()?.getSelectedText()) return
if (delivery() === "steer") {
dialog.replace(() => (
<DialogSelect
title="Pending steer"
options={[
{ title: "Move to queue", value: "queue" as const },
{ title: "Delete", value: "cancel" as const },
]}
onSelect={(option) => {
void updatePendingSteer(option.value)
}}
/>
))
return
}
dialog.replace(() => ( dialog.replace(() => (
<DialogMessage <DialogMessage
messageID={props.message.id} messageID={props.message.id}
@@ -2009,35 +1910,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
) )
} }
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const next = createMemo(() => props.prompts[0]?.text)
return (
<box
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseUp={props.onOpen}
>
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
<Show when={next()}>{(text) => <> · {text()}</>}</Show>
</text>
</box>
</box>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme() const theme = useTheme()
return ( return (
@@ -2062,7 +1934,6 @@ function ReasoningPart(props: {
}) { }) {
const theme = useTheme() const theme = useTheme()
const { currentSyntax: syntax } = useThemes() const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use() const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the // Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close. // layout never shifts. Click to open the full markdown block, click to close.
@@ -2115,7 +1986,7 @@ function ReasoningPart(props: {
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
streaming={true} streaming={true}
syntaxStyle={thinkingSyntax()} syntaxStyle={syntax()}
content={content()} content={content()}
conceal={ctx.markdownMode() === "rendered"} conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued} fg={theme.text.subdued}
+8 -15
View File
@@ -46,14 +46,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
function reduce() { function reduce() {
const messages = data.session.message.list(sessionID()) const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID())) const inputs = new Set(data.session.input.list(sessionID()))
const pending = data.session.pending.list(sessionID())
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
const boundary = revertBoundary() const boundary = revertBoundary()
const rows = reduceSessionRows( const rows = reduceSessionRows(
boundary ? visible.filter((message) => message.id < boundary) : visible, boundary ? messages.filter((message) => message.id < boundary) : messages,
inputs, inputs,
turnTokens(), turnTokens(),
) )
@@ -62,7 +57,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
rows.splice( rows.splice(
position === -1 ? rows.length : position, position === -1 ? rows.length : position,
0, 0,
...pending ...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction") .filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
) )
@@ -116,11 +112,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect( createEffect(
on( on(
() => () =>
data.session.pending.list(sessionID()).flatMap((item) => { data.session.pending
if (item.type === "compaction") return [`${item.id}:compaction`] .list(sessionID())
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`] .filter((item) => item.type === "compaction")
return [] .map((item) => item.id),
}),
() => setRows(reconcile(reduce())), () => setRows(reconcile(reduce())),
{ defer: true }, { defer: true },
), ),
@@ -201,9 +196,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
const queuedStart = (rows: SessionRow[]) => { const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex( const index = rows.findIndex(
(row) => (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
row.type === "compaction-queued" ||
(row.type === "message" && isPending(row.messageID)),
) )
return index === -1 ? rows.length : index return index === -1 ? rows.length : index
} }
@@ -1,9 +0,0 @@
import { SyntaxStyle, type RGBA } from "@opentui/core"
export function generateThinkingSyntax(syntax: SyntaxStyle, foreground: RGBA) {
return SyntaxStyle.fromStyles(
Object.fromEntries(
syntax.getRegisteredNames().map((name) => [name, { ...syntax.getStyle(name), fg: foreground }]),
),
)
}
-12
View File
@@ -1,12 +0,0 @@
export function createSingleFlight<Key>() {
const pending = new Set<Key>()
return async <Value>(key: Key, run: () => Promise<Value>) => {
if (pending.has(key)) return
pending.add(key)
try {
return await run()
} finally {
pending.delete(key)
}
}
}
-1
View File
@@ -22,7 +22,6 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
export function webSearchProviderLabel(provider: unknown) { export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search" if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search" if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search" return "Web Search"
} }
-100
View File
@@ -914,106 +914,6 @@ test("completes exploration when a queued prompt is promoted", async () => {
} }
}) })
test("updates and removes queued inputs from durable lifecycle events", async () => {
const events = createEventStream()
const sessionID = "session-queue-management"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
client = useClient()
data = useData()
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_queue_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID),
data: {
sessionID,
inputID: "message-queued",
input: { type: "user", data: { text: "Steer me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_steered",
created: 2,
type: "session.input.steered",
durable: durable(sessionID, 1),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_restored",
created: 3,
type: "session.input.queued",
durable: durable(sessionID, 2),
data: { sessionID, inputID: "message-queued" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_cancel_admitted",
created: 4,
type: "session.input.admitted",
durable: durable(sessionID, 3),
data: {
sessionID,
inputID: "message-cancelled",
input: { type: "user", data: { text: "Delete me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 2)
emitEvent(events, {
id: "evt_queue_cancelled",
created: 5,
type: "session.input.cancelled",
durable: durable(sessionID, 4),
data: { sessionID, inputID: "message-cancelled" },
})
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => { test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream() const events = createEventStream()
const sessionID = "session-tool-call-id" const sessionID = "session-tool-call-id"
+10 -12
View File
@@ -1,6 +1,9 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { mkdtempSync, rmSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { DialogOpen } from "../../../src/component/dialog-open" import { DialogOpen } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config" import { ConfigProvider } from "../../../src/config"
@@ -11,13 +14,12 @@ import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider, useRoute } from "../../../src/context/route" import { RouteProvider, useRoute } from "../../../src/context/route"
import { TuiAppProvider } from "../../../src/context/runtime" import { TuiAppProvider } from "../../../src/context/runtime"
import { SessionTabsProvider } from "../../../src/context/session-tabs" import { SessionTabsProvider } from "../../../src/context/session-tabs"
import { StorageProvider, useStorage } from "../../../src/context/storage" import { StorageProvider } from "../../../src/context/storage"
import { ThemeProvider } from "../../../src/context/theme" import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast" import { ToastProvider } from "../../../src/ui/toast"
import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch, json, type FetchHandler } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("selecting an unhydrated session preserves its location", async () => { test("selecting an unhydrated session preserves its location", async () => {
@@ -50,7 +52,7 @@ test("selecting an unhydrated session preserves its location", async () => {
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" }) expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_remote" })
expect(fixture.location.ref).toEqual(remote) expect(fixture.location.ref).toEqual(remote)
} finally { } finally {
await fixture.dispose() fixture.dispose()
} }
}) })
@@ -92,7 +94,7 @@ test("shows the current project and opens its root", async () => {
expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } }) expect(fixture.route.data).toEqual({ type: "home", location: { directory: root } })
expect(fixture.location.ref).toEqual({ directory: root }) expect(fixture.location.ref).toEqual({ directory: root })
} finally { } finally {
await fixture.dispose() fixture.dispose()
} }
}) })
@@ -147,7 +149,7 @@ test("preserves a moved project when sessions arrive", async () => {
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } }) expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/second" } })
} finally { } finally {
await fixture.dispose() fixture.dispose()
} }
}) })
@@ -158,21 +160,18 @@ async function renderOpen(
location: ReturnType<typeof useLocation> location: ReturnType<typeof useLocation>
}) => void | Promise<void>, }) => void | Promise<void>,
) { ) {
const temporary = await tmpdir() const state = mkdtempSync(path.join(tmpdir(), "opencode-dialog-open-"))
const state = temporary.path
const events = createEventStream() const events = createEventStream()
const calls = createFetch(handler, events) const calls = createFetch(handler, events)
let route!: ReturnType<typeof useRoute> let route!: ReturnType<typeof useRoute>
let location!: ReturnType<typeof useLocation> let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData> let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
function Probe() { function Probe() {
const dialog = useDialog() const dialog = useDialog()
route = useRoute() route = useRoute()
location = useLocation() location = useLocation()
data = useData() data = useData()
storage = useStorage()
onMount( onMount(
() => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)), () => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => <DialogOpen />)),
) )
@@ -224,10 +223,9 @@ async function renderOpen(
get data() { get data() {
return data return data
}, },
async dispose() { dispose() {
app.renderer.destroy() app.renderer.destroy()
await storage.flush() rmSync(state, { recursive: true, force: true })
await temporary[Symbol.asyncDispose]()
}, },
} }
} }

Some files were not shown because too many files have changed in this diff Show More