mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 18:30:00 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55874ccaeb | |||
| b8ed70d6d3 |
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>>) {
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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] })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"})`,
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,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(
|
||||||
|
|||||||
@@ -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}`))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) })),
|
||||||
|
|||||||
@@ -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] })
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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("\\", "/"),
|
||||||
|
])
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user