mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-10 16:04:58 +00:00
Compare commits
13 Commits
kit/tool-e
...
kit/tool-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8315bdf77 | ||
|
|
bf601628db | ||
|
|
00e39d2114 | ||
|
|
46b74e0873 | ||
|
|
aedc4e964f | ||
|
|
e83404367c | ||
|
|
42206da1f8 | ||
|
|
44f38193c0 | ||
|
|
9a6b455bfe | ||
|
|
8063e0b5c6 | ||
|
|
157c5d77f8 | ||
|
|
ce19c051be | ||
|
|
91786d2fc1 |
@@ -4,6 +4,8 @@ export const GlobalBus = new EventEmitter<{
|
||||
event: [
|
||||
{
|
||||
directory?: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: any
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
@@ -91,8 +91,13 @@ export namespace Bus {
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
const context = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: dir,
|
||||
project: context.project.id,
|
||||
workspace,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
onCleanup,
|
||||
} from "solid-js"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -23,6 +22,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
|
||||
import { ErrorComponent } from "@tui/component/error-component"
|
||||
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
|
||||
import { ProjectProvider } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { StartupLoading } from "@tui/component/startup-loading"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
@@ -54,7 +55,6 @@ import { KVProvider, useKV } from "./context/kv"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
@@ -216,27 +216,29 @@ export function tui(input: {
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
@@ -260,6 +262,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const keybind = useKeybind()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
@@ -283,6 +286,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
route,
|
||||
routes,
|
||||
bump: () => setRouteRev((x) => x + 1),
|
||||
event,
|
||||
sdk,
|
||||
sync,
|
||||
theme: themeState,
|
||||
@@ -491,12 +495,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
const current = promptRef.current
|
||||
// Don't require focus - if there's any text, preserve it
|
||||
const currentPrompt = current?.current?.input ? current.current : undefined
|
||||
const workspaceID =
|
||||
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
|
||||
route.navigate({
|
||||
type: "home",
|
||||
initialPrompt: currentPrompt,
|
||||
workspaceID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -806,11 +807,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
])
|
||||
|
||||
sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
@@ -819,14 +820,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
event.on(TuiEvent.SessionSelect.type, (evt) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: evt.properties.sessionID,
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on("session.deleted", (evt) => {
|
||||
event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -836,7 +837,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
})
|
||||
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = errorMessage(error)
|
||||
@@ -848,7 +849,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on("installation.update-available", async (evt) => {
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.properties.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
@@ -14,7 +15,7 @@ function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
directory: sync.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
@@ -149,6 +150,7 @@ function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promi
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
@@ -168,8 +170,9 @@ export function DialogWorkspaceList() {
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string) {
|
||||
if (workspaceID === "__local__") {
|
||||
async function selectWorkspace(workspaceID: string | null) {
|
||||
if (workspaceID == null) {
|
||||
project.workspace.set(undefined)
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
@@ -199,12 +202,7 @@ export function DialogWorkspaceList() {
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => {
|
||||
if (route.data.type === "session") {
|
||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||
}
|
||||
return "__local__"
|
||||
})
|
||||
const currentWorkspaceID = createMemo(() => project.workspace.current())
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
@@ -234,7 +232,7 @@ export function DialogWorkspaceList() {
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: "__local__",
|
||||
value: null,
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
@@ -292,7 +290,7 @@ export function DialogWorkspaceList() {
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === "__local__") return
|
||||
if (option.value === "__create__" || option.value === null) return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
@@ -307,6 +305,7 @@ export function DialogWorkspaceList() {
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
|
||||
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
|
||||
const fullPath = `${baseDir}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
@@ -115,8 +116,9 @@ export function Prompt(props: PromptProps) {
|
||||
const agentStyleId = syntax().getStyleId("extmark.agent")!
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId = 0
|
||||
const event = useEvent()
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.insertText(evt.properties.text)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useProject } from "./project"
|
||||
import { useSync } from "./sync"
|
||||
import { Global } from "@/global"
|
||||
|
||||
export function useDirectory() {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
return createMemo(() => {
|
||||
const directory = sync.data.path.directory || process.cwd()
|
||||
const directory = project.instance.path().directory || process.cwd()
|
||||
const result = directory.replace(Global.Path.home, "~")
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
|
||||
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
41
packages/opencode/src/cli/cmd/tui/context/event.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { useProject } from "./project"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export function useEvent() {
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
function subscribe(handler: (event: Event) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
if (project.workspace.current()) {
|
||||
if (event.workspace === project.workspace.current()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.directory === project.instance.directory()) {
|
||||
handler(event.payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
|
||||
return subscribe((event) => {
|
||||
if (event.type !== type) return
|
||||
handler(event as Extract<Event, { type: T }>)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
on,
|
||||
}
|
||||
}
|
||||
65
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
65
packages/opencode/src/cli/cmd/tui/context/project.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { batch } from "solid-js"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = createStore({
|
||||
project: {
|
||||
id: undefined as string | undefined,
|
||||
},
|
||||
instance: {
|
||||
path: {
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: sdk.directory ?? "",
|
||||
} satisfies Path,
|
||||
},
|
||||
workspace: undefined as string | undefined,
|
||||
})
|
||||
|
||||
async function sync() {
|
||||
const workspace = store.workspace
|
||||
const [path, project] = await Promise.all([
|
||||
sdk.client.path.get({ workspace }),
|
||||
sdk.client.project.current({ workspace }),
|
||||
])
|
||||
|
||||
batch(() => {
|
||||
setStore("instance", "path", reconcile(path.data!))
|
||||
setStore("project", "id", project.data?.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
project() {
|
||||
return store.project.id
|
||||
},
|
||||
instance: {
|
||||
path() {
|
||||
return store.instance.path
|
||||
},
|
||||
directory() {
|
||||
return store.instance.path.directory
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
current() {
|
||||
return store.workspace
|
||||
},
|
||||
set(next?: string | null) {
|
||||
const workspace = next ?? undefined
|
||||
if (store.workspace === workspace) return
|
||||
setStore("workspace", workspace)
|
||||
},
|
||||
},
|
||||
sync,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -5,7 +5,6 @@ import type { PromptInfo } from "../component/prompt/history"
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
initialPrompt?: PromptInfo
|
||||
workspaceID?: string
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
|
||||
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -32,10 +33,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
event: GlobalEvent
|
||||
}>()
|
||||
|
||||
let queue: Event[] = []
|
||||
let queue: GlobalEvent[] = []
|
||||
let timer: Timer | undefined
|
||||
let last = 0
|
||||
|
||||
@@ -48,12 +49,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
// Batch all event emissions so all store updates result in a single render
|
||||
batch(() => {
|
||||
for (const event of events) {
|
||||
emitter.emit(event.type, event)
|
||||
emitter.emit("event", event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEvent = (event: Event) => {
|
||||
const handleEvent = (event: GlobalEvent) => {
|
||||
queue.push(event)
|
||||
const elapsed = Date.now() - last
|
||||
|
||||
@@ -74,7 +75,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
const events = await sdk.global.event({ signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
@@ -89,7 +90,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
|
||||
onMount(async () => {
|
||||
if (props.events) {
|
||||
const unsub = await props.events.subscribe(props.directory, handleEvent)
|
||||
const unsub = await props.events.subscribe(handleEvent)
|
||||
onCleanup(unsub)
|
||||
} else {
|
||||
startSSE()
|
||||
|
||||
@@ -17,18 +17,19 @@ import type {
|
||||
ProviderListResponse,
|
||||
ProviderAuthMethod,
|
||||
VcsInfo,
|
||||
Workspace,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { batch, createEffect, on } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
@@ -74,9 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
[key: string]: McpResource
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -103,21 +103,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const workspace = project.workspace.current()
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
if (!result.data.some((item) => item.id === workspace)) {
|
||||
project.workspace.set(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
bootstrap()
|
||||
@@ -344,7 +348,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
case "lsp.updated": {
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
||||
const workspace = project.workspace.current()
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -360,25 +365,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
async function bootstrap() {
|
||||
console.log("bootstrapping")
|
||||
const workspace = project.workspace.current()
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start })
|
||||
.list({ start: start, workspace })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
|
||||
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
|
||||
const consoleStatePromise = sdk.client.experimental.console
|
||||
.get({}, { throwOnError: true })
|
||||
.get({ workspace }, { throwOnError: true })
|
||||
.then((x) => ConsoleState.parse(x.data))
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({}, { throwOnError: true })
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const projectPromise = project.sync()
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
]
|
||||
|
||||
@@ -423,17 +431,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
Promise.all([
|
||||
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
|
||||
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
|
||||
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status().then((x) => {
|
||||
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
|
||||
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))),
|
||||
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))),
|
||||
sdk.client.experimental.resource
|
||||
.list({ workspace })
|
||||
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
|
||||
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
|
||||
sdk.client.session.status({ workspace }).then((x) => {
|
||||
setStore("session_status", reconcile(x.data!))
|
||||
}),
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
@@ -449,11 +458,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bootstrap()
|
||||
})
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
createEffect(
|
||||
on(
|
||||
() => project.workspace.current(),
|
||||
() => {
|
||||
fullSyncedSessions.clear()
|
||||
void bootstrap()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
set: setStore,
|
||||
@@ -463,6 +478,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
@@ -481,11 +499,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
if (fullSyncedSessions.has(sessionID)) return
|
||||
const workspace = project.workspace.current()
|
||||
const [session, messages, todo, diff] = await Promise.all([
|
||||
sdk.client.session.get({ sessionID }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100 }),
|
||||
sdk.client.session.todo({ sessionID }),
|
||||
sdk.client.session.diff({ sessionID }),
|
||||
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }),
|
||||
sdk.client.session.messages({ sessionID, limit: 100, workspace }),
|
||||
sdk.client.session.todo({ sessionID, workspace }),
|
||||
sdk.client.session.diff({ sessionID, workspace }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -504,8 +523,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return store.workspaceList
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
return store.workspaceList.find((item) => item.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useEvent } from "@tui/context/event"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
@@ -36,6 +37,7 @@ type Input = {
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
event: ReturnType<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
@@ -136,7 +138,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return sync.data.path
|
||||
return sync.path
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
@@ -342,7 +344,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
event: input.sdk.event,
|
||||
event: input.event,
|
||||
renderer: input.renderer,
|
||||
slots: {
|
||||
register() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useProject } from "../context/project"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
@@ -18,6 +19,7 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -63,11 +65,16 @@ export function Home() {
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
|
||||
<TuiPluginRuntime.Slot
|
||||
name="home_prompt"
|
||||
mode="replace"
|
||||
workspace_id={project.workspace.current()}
|
||||
ref={bind}
|
||||
>
|
||||
<Prompt
|
||||
ref={bind}
|
||||
workspaceID={route.workspaceID}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
|
||||
workspaceID={project.workspace.current()}
|
||||
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />}
|
||||
placeholders={placeholder}
|
||||
/>
|
||||
</TuiPluginRuntime.Slot>
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import path from "path"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
@@ -116,6 +118,8 @@ export function Session() {
|
||||
const route = useRouteData("session")
|
||||
const { navigate } = useRoute()
|
||||
const sync = useSync()
|
||||
const event = useEvent()
|
||||
const project = useProject()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
@@ -172,10 +176,16 @@ export function Session() {
|
||||
const providers = createMemo(() => Model.index(sync.data.provider))
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
await sdk.client.session
|
||||
.get({ sessionID: route.sessionID }, { throwOnError: true })
|
||||
.then((x) => {
|
||||
project.workspace.set(x.data?.workspaceID)
|
||||
})
|
||||
.then(() => sync.session.sync(route.sessionID))
|
||||
.then(() => {
|
||||
if (scroll) scroll.scrollBy(100_000)
|
||||
})
|
||||
@@ -189,13 +199,10 @@ export function Session() {
|
||||
})
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
|
||||
// Handle initial prompt from fork
|
||||
let seeded = false
|
||||
let lastSwitch: string | undefined = undefined
|
||||
sdk.event.on("message.part.updated", (evt) => {
|
||||
event.on("message.part.updated", (evt) => {
|
||||
const part = evt.properties.part
|
||||
if (part.type !== "tool") return
|
||||
if (part.sessionID !== route.sessionID) return
|
||||
@@ -224,7 +231,7 @@ export function Session() {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== route.sessionID) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||
@@ -1791,7 +1798,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
|
||||
const workdir = props.input.workdir
|
||||
if (!workdir || workdir === ".") return undefined
|
||||
|
||||
const base = sync.data.path.directory
|
||||
const base = sync.path.directory
|
||||
if (!base) return undefined
|
||||
|
||||
const absolute = path.resolve(base, workdir)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
@@ -43,18 +43,10 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
subscribe: async (directory, handler) => {
|
||||
const id = await client.call("subscribe", { directory })
|
||||
const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
|
||||
if (e.id === id) {
|
||||
handler(e.event)
|
||||
}
|
||||
subscribe: async (handler) => {
|
||||
return client.on<GlobalEvent>("global.event", (e) => {
|
||||
handler(e)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
client.call("unsubscribe", { id })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Heap } from "@/cli/heap"
|
||||
|
||||
await Log.init({
|
||||
@@ -45,87 +42,6 @@ GlobalBus.on("event", (event) => {
|
||||
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
|
||||
const eventStreams = new Map<string, AbortController>()
|
||||
|
||||
function startEventStream(directory: string) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const abort = new AbortController()
|
||||
const signal = abort.signal
|
||||
|
||||
eventStreams.set(id, abort)
|
||||
|
||||
async function run() {
|
||||
while (!signal.aborted) {
|
||||
const shouldReconnect = await Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
Rpc.emit("event", {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
} satisfies Event)
|
||||
|
||||
let settled = false
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
Rpc.emit("event", {
|
||||
id,
|
||||
event: event as Event,
|
||||
})
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
settle(false)
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
}),
|
||||
}).catch((error) => {
|
||||
Log.Default.error("event stream subscribe error", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!shouldReconnect || signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
Log.Default.error("event stream error", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function stopEventStream(id: string) {
|
||||
const abortController = eventStreams.get(id)
|
||||
if (!abortController) return
|
||||
|
||||
abortController.abort()
|
||||
eventStreams.delete(id)
|
||||
}
|
||||
|
||||
export const rpc = {
|
||||
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
|
||||
const headers = { ...input.headers }
|
||||
@@ -167,19 +83,9 @@ export const rpc = {
|
||||
async reload() {
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async subscribe(input: { directory: string | undefined }) {
|
||||
return startEventStream(input.directory || process.cwd())
|
||||
},
|
||||
async unsubscribe(input: { id: string }) {
|
||||
stopEventStream(input.id)
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
for (const id of [...eventStreams.keys()]) {
|
||||
stopEventStream(id)
|
||||
}
|
||||
|
||||
await Instance.disposeAll()
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
|
||||
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
22
packages/opencode/src/control-plane/workspace-context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Context } from "../util/context"
|
||||
import type { WorkspaceID } from "../control-plane/schema"
|
||||
|
||||
export interface WorkspaceContext {
|
||||
workspaceID: string
|
||||
}
|
||||
|
||||
const context = Context.create<WorkspaceContext>("instance")
|
||||
|
||||
export const WorkspaceContext = {
|
||||
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
|
||||
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
|
||||
},
|
||||
|
||||
get workspaceID() {
|
||||
try {
|
||||
return context.use().workspaceID
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -134,12 +134,12 @@ export namespace Workspace {
|
||||
continue
|
||||
}
|
||||
|
||||
await parseSSE(res.body, stop, (event) => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: space.id,
|
||||
payload: event,
|
||||
})
|
||||
})
|
||||
// await parseSSE(res.body, stop, (event) => {
|
||||
// GlobalBus.emit("event", {
|
||||
// directory: space.id,
|
||||
// payload: event,
|
||||
// })
|
||||
// })
|
||||
|
||||
// Wait 250ms and retry if SSE connection fails
|
||||
await sleep(250)
|
||||
|
||||
@@ -4,3 +4,7 @@ import type { InstanceContext } from "@/project/instance"
|
||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export const WorkspaceRef = ServiceMap.Reference<string | undefined>("~opencode/WorkspaceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
|
||||
@@ -28,6 +29,10 @@ export namespace InstanceState {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})
|
||||
|
||||
export const workspaceID = Effect.gen(function* () {
|
||||
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
|
||||
})
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
export const make = <A, E = never, R = never>(
|
||||
|
||||
@@ -2,15 +2,17 @@ import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { InstanceRef, WorkspaceRef } from "./instance-ref"
|
||||
import { Observability } from "./oltp"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
const ctx = Instance.current
|
||||
return Effect.provideService(effect, InstanceRef, ctx)
|
||||
const workspaceID = WorkspaceContext.workspaceID
|
||||
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
@@ -344,6 +343,7 @@ export namespace File {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("File.state")(() =>
|
||||
@@ -410,6 +410,10 @@ export namespace File {
|
||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
})
|
||||
|
||||
const gitText = Effect.fnUntraced(function* (args: string[]) {
|
||||
return (yield* git.run(args, { cwd: Instance.directory })).text()
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
yield* ensure()
|
||||
})
|
||||
@@ -417,100 +421,87 @@ export namespace File {
|
||||
const status = Effect.fn("File.status")(function* () {
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
const diffOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--numstat",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
).text()
|
||||
|
||||
const changed: File.Info[] = []
|
||||
|
||||
if (diffOutput.trim()) {
|
||||
for (const line of diffOutput.trim().split("\n")) {
|
||||
const [added, removed, file] = line.split("\t")
|
||||
changed.push({
|
||||
path: file,
|
||||
added: added === "-" ? 0 : parseInt(added, 10),
|
||||
removed: removed === "-" ? 0 : parseInt(removed, 10),
|
||||
status: "modified",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const untrackedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
])
|
||||
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
try {
|
||||
const content = await Filesystem.readText(path.join(Instance.directory, file))
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (untrackedOutput.trim()) {
|
||||
for (const file of untrackedOutput.trim().split("\n")) {
|
||||
const content = yield* appFs
|
||||
.readFileString(path.join(Instance.directory, file))
|
||||
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
|
||||
if (content === undefined) continue
|
||||
changed.push({
|
||||
path: file,
|
||||
added: content.split("\n").length,
|
||||
removed: 0,
|
||||
status: "added",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
],
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
},
|
||||
)
|
||||
).text()
|
||||
const deletedOutput = yield* gitText([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--diff-filter=D",
|
||||
"HEAD",
|
||||
])
|
||||
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
if (deletedOutput.trim()) {
|
||||
for (const file of deletedOutput.trim().split("\n")) {
|
||||
changed.push({
|
||||
path: file,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
status: "deleted",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
return changed.map((item) => {
|
||||
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
|
||||
return {
|
||||
...item,
|
||||
path: path.relative(Instance.directory, full),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const read = Effect.fn("File.read")(function* (file: string) {
|
||||
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
|
||||
using _ = log.time("read", { file })
|
||||
const full = path.join(Instance.directory, file)
|
||||
|
||||
@@ -558,27 +549,19 @@ export namespace File {
|
||||
)
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||
let diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text", content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text", content }
|
||||
})
|
||||
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
|
||||
if (!diff.trim()) {
|
||||
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = yield* git.show(Instance.directory, "HEAD", file)
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
})
|
||||
return { type: "text" as const, content, patch, diff: formatPatch(patch) }
|
||||
}
|
||||
return { type: "text" as const, content }
|
||||
}
|
||||
|
||||
return { type: "text" as const, content }
|
||||
@@ -660,7 +643,7 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ export namespace FileWatcher {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
@@ -131,11 +132,9 @@ export namespace FileWatcher {
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
})
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
@@ -161,7 +160,7 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -105,17 +105,7 @@ export namespace LSPServer {
|
||||
if (!tsserver) return
|
||||
const bin = await Npm.which("typescript-language-server")
|
||||
if (!bin) return
|
||||
|
||||
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
|
||||
|
||||
if (
|
||||
!(await pathExists(path.join(root, "tsconfig.json"))) &&
|
||||
!(await pathExists(path.join(root, "jsconfig.json")))
|
||||
) {
|
||||
args.push("--ignore-node-modules")
|
||||
}
|
||||
|
||||
const proc = spawn(bin, args, {
|
||||
const proc = spawn(bin, ["--stdio"], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { State } from "./state"
|
||||
|
||||
export interface InstanceContext {
|
||||
@@ -20,19 +21,9 @@ const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
function emitDisposed(directory: string) {}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
@@ -93,6 +84,7 @@ export const Instance = {
|
||||
get project() {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside Instance.directory OR Instance.worktree.
|
||||
@@ -131,15 +123,39 @@ export const Instance = {
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
project: input.project?.id,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
const directory = Instance.directory
|
||||
const project = Instance.project
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
emit(directory)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
project: project.id,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
||||
@@ -137,6 +137,8 @@ export namespace Project {
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
project: data.id,
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,9 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
@@ -26,6 +29,16 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
async function getSessionWorkspace(url: URL) {
|
||||
if (url.pathname === "/session/status") return null
|
||||
|
||||
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(SessionID.make(id)).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||
|
||||
@@ -42,13 +55,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
)
|
||||
|
||||
const url = new URL(c.req.url)
|
||||
const workspaceParam = url.searchParams.get("workspace") || c.req.header("x-opencode-workspace")
|
||||
|
||||
// TODO: If session is being routed, force it to lookup the
|
||||
// project/workspace
|
||||
const sessionWorkspaceID = await getSessionWorkspace(url)
|
||||
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
|
||||
|
||||
// If no workspace is provided we use the "project" workspace
|
||||
if (!workspaceParam) {
|
||||
// If no workspace is provided we use the project
|
||||
if (!workspaceID) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
@@ -58,8 +70,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
||||
const workspace = await Workspace.get(workspaceID)
|
||||
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
@@ -73,12 +84,16 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
const target = await adaptor.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
return Instance.provide({
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(workspaceID),
|
||||
fn: () =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export const GlobalRoutes = lazy(() =>
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: BusEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -11,7 +11,11 @@ import { Git } from "@/git"
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
|
||||
type Migration = (
|
||||
dir: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
) => Effect.Effect<void, AppFileSystem.Error>
|
||||
|
||||
export const NotFoundError = NamedError.create(
|
||||
"NotFoundError",
|
||||
@@ -83,7 +87,7 @@ export namespace Storage {
|
||||
}
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
|
||||
const project = path.resolve(dir, "../project")
|
||||
if (!(yield* fs.isDir(project))) return
|
||||
const projectDirs = yield* fs.glob("*", {
|
||||
@@ -110,11 +114,9 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(yield* fs.isDir(worktree))) continue
|
||||
const result = yield* Effect.promise(() =>
|
||||
Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
}),
|
||||
)
|
||||
const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
.text()
|
||||
.split("\n")
|
||||
@@ -220,6 +222,7 @@ export namespace Storage {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const locks = yield* RcMap.make({
|
||||
lookup: () => TxReentrantLock.make(),
|
||||
idleTimeToLive: 0,
|
||||
@@ -236,7 +239,7 @@ export namespace Storage {
|
||||
for (let i = migration; i < MIGRATIONS.length; i++) {
|
||||
log.info("running migration", { index: i })
|
||||
const step = MIGRATIONS[i]!
|
||||
const exit = yield* Effect.exit(step(dir, fs))
|
||||
const exit = yield* Effect.exit(step(dir, fs, git))
|
||||
if (Exit.isFailure(exit)) {
|
||||
log.error("failed to run migration", { index: i, cause: exit.cause })
|
||||
break
|
||||
@@ -327,7 +330,7 @@ export namespace Storage {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ import { Shell } from "@/shell/shell"
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import { Truncate } from "./truncate"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Cause, Effect, Exit, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
@@ -330,7 +331,8 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
|
||||
})
|
||||
}
|
||||
|
||||
async function run(
|
||||
const run = Effect.fn("BashTool.run")(function* (
|
||||
spawner: ChildProcessSpawner["Service"],
|
||||
input: {
|
||||
shell: string
|
||||
name: string
|
||||
@@ -353,7 +355,7 @@ async function run(
|
||||
},
|
||||
})
|
||||
|
||||
const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
|
||||
const code: number | null = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
|
||||
|
||||
@@ -396,15 +398,8 @@ async function run(
|
||||
}
|
||||
|
||||
return exit.kind === "exit" ? exit.code : null
|
||||
}).pipe(Effect.scoped, Effect.orDie),
|
||||
)
|
||||
|
||||
let code: number | null = null
|
||||
if (Exit.isSuccess(exit)) {
|
||||
code = exit.value
|
||||
} else if (!Cause.hasInterruptsOnly(exit.cause)) {
|
||||
throw Cause.squash(exit.cause)
|
||||
}
|
||||
}),
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
const meta: string[] = []
|
||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
@@ -422,7 +417,7 @@ async function run(
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const parser = lazy(async () => {
|
||||
const { Parser } = await import("web-tree-sitter")
|
||||
@@ -452,47 +447,52 @@ const parser = lazy(async () => {
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.define("bash", async () => {
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
export const BashTool = Tool.defineEffect(
|
||||
"bash",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = await parse(params.command, ps)
|
||||
const scan = await collect(root, cwd, ps, shell)
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
await ask(ctx, scan)
|
||||
const shell = Shell.acceptable()
|
||||
const name = Shell.name(shell)
|
||||
const chain =
|
||||
name === "powershell"
|
||||
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
|
||||
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
log.info("bash tool using shell", { shell })
|
||||
|
||||
return run(
|
||||
{
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: await shellEnv(ctx, cwd),
|
||||
timeout,
|
||||
description: params.description,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
return {
|
||||
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
|
||||
.replaceAll("${os}", process.platform)
|
||||
.replaceAll("${shell}", name)
|
||||
.replaceAll("${chaining}", chain)
|
||||
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
|
||||
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const cwd = params.workdir
|
||||
? yield* Effect.promise(() => resolvePath(params.workdir!, Instance.directory, shell))
|
||||
: Instance.directory
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? DEFAULT_TIMEOUT
|
||||
const ps = PS.has(name)
|
||||
const root = yield* Effect.promise(() => parse(params.command, ps))
|
||||
const scan = yield* Effect.promise(() => collect(root, cwd, ps, shell))
|
||||
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
|
||||
yield* Effect.promise(() => ask(ctx, scan))
|
||||
|
||||
return yield* run(spawner, {
|
||||
shell,
|
||||
name,
|
||||
command: params.command,
|
||||
cwd,
|
||||
env: yield* Effect.promise(() => shellEnv(ctx, cwd)),
|
||||
timeout,
|
||||
description: params.description,
|
||||
}, ctx)
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,132 +1,65 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Tool } from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
ENDPOINTS: {
|
||||
CONTEXT: "/mcp",
|
||||
},
|
||||
} as const
|
||||
export const CodeSearchTool = Tool.defineEffect(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
interface McpCodeRequest {
|
||||
jsonrpc: string
|
||||
id: number
|
||||
method: string
|
||||
params: {
|
||||
name: string
|
||||
arguments: {
|
||||
query: string
|
||||
tokensNum: number
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
interface McpCodeResponse {
|
||||
jsonrpc: string
|
||||
result: {
|
||||
content: Array<{
|
||||
type: string
|
||||
text: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
"get_code_context_exa",
|
||||
McpExa.CodeArgs,
|
||||
{
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
},
|
||||
"30 seconds",
|
||||
)
|
||||
|
||||
export const CodeSearchTool = Tool.define("codesearch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'",
|
||||
),
|
||||
tokensNum: z
|
||||
.number()
|
||||
.min(1000)
|
||||
.max(50000)
|
||||
.default(5000)
|
||||
.describe(
|
||||
"Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.",
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
})
|
||||
|
||||
const codeRequest: McpCodeRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "get_code_context_exa",
|
||||
arguments: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum || 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(codeRequest),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Code search error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
// Parse SSE response
|
||||
const lines = responseText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data: McpCodeResponse = JSON.parse(line.substring(6))
|
||||
if (data.result && data.result.content && data.result.content.length > 0) {
|
||||
return {
|
||||
output: data.result.content[0].text,
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
return {
|
||||
output:
|
||||
result ??
|
||||
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output:
|
||||
"No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.",
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Code search request timed out")
|
||||
}
|
||||
|
||||
throw error
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
import DESCRIPTION from "./lsp.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { pathToFileURL } from "url"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
const operations = [
|
||||
"goToDefinition",
|
||||
@@ -20,78 +21,71 @@ const operations = [
|
||||
"outgoingCalls",
|
||||
] as const
|
||||
|
||||
export const LspTool = Tool.define("lsp", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
operation: z.enum(operations).describe("The LSP operation to perform"),
|
||||
filePath: z.string().describe("The absolute or relative path to the file"),
|
||||
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
|
||||
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
||||
}),
|
||||
execute: async (args, ctx) => {
|
||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||
await assertExternalDirectory(ctx, file)
|
||||
|
||||
await ctx.ask({
|
||||
permission: "lsp",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
const uri = pathToFileURL(file).href
|
||||
const position = {
|
||||
file,
|
||||
line: args.line - 1,
|
||||
character: args.character - 1,
|
||||
}
|
||||
|
||||
const relPath = path.relative(Instance.worktree, file)
|
||||
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
|
||||
|
||||
const exists = await Filesystem.exists(file)
|
||||
if (!exists) {
|
||||
throw new Error(`File not found: ${file}`)
|
||||
}
|
||||
|
||||
const available = await LSP.hasClients(file)
|
||||
if (!available) {
|
||||
throw new Error("No LSP server available for this file type.")
|
||||
}
|
||||
|
||||
await LSP.touchFile(file, true)
|
||||
|
||||
const result: unknown[] = await (async () => {
|
||||
switch (args.operation) {
|
||||
case "goToDefinition":
|
||||
return LSP.definition(position)
|
||||
case "findReferences":
|
||||
return LSP.references(position)
|
||||
case "hover":
|
||||
return LSP.hover(position)
|
||||
case "documentSymbol":
|
||||
return LSP.documentSymbol(uri)
|
||||
case "workspaceSymbol":
|
||||
return LSP.workspaceSymbol("")
|
||||
case "goToImplementation":
|
||||
return LSP.implementation(position)
|
||||
case "prepareCallHierarchy":
|
||||
return LSP.prepareCallHierarchy(position)
|
||||
case "incomingCalls":
|
||||
return LSP.incomingCalls(position)
|
||||
case "outgoingCalls":
|
||||
return LSP.outgoingCalls(position)
|
||||
}
|
||||
})()
|
||||
|
||||
const output = (() => {
|
||||
if (result.length === 0) return `No results found for ${args.operation}`
|
||||
return JSON.stringify(result, null, 2)
|
||||
})()
|
||||
export const LspTool = Tool.defineEffect(
|
||||
"lsp",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
return {
|
||||
title,
|
||||
metadata: { result },
|
||||
output,
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
operation: z.enum(operations).describe("The LSP operation to perform"),
|
||||
filePath: z.string().describe("The absolute or relative path to the file"),
|
||||
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
|
||||
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
|
||||
}),
|
||||
execute: (
|
||||
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
|
||||
ctx: Tool.Context,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, file)
|
||||
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
|
||||
|
||||
const uri = pathToFileURL(file).href
|
||||
const position = { file, line: args.line - 1, character: args.character - 1 }
|
||||
const relPath = path.relative(Instance.worktree, file)
|
||||
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
|
||||
|
||||
const exists = yield* fs.existsSafe(file)
|
||||
if (!exists) throw new Error(`File not found: ${file}`)
|
||||
|
||||
const available = yield* lsp.hasClients(file)
|
||||
if (!available) throw new Error("No LSP server available for this file type.")
|
||||
|
||||
yield* lsp.touchFile(file, true)
|
||||
|
||||
const result: unknown[] = yield* (() => {
|
||||
switch (args.operation) {
|
||||
case "goToDefinition":
|
||||
return lsp.definition(position)
|
||||
case "findReferences":
|
||||
return lsp.references(position)
|
||||
case "hover":
|
||||
return lsp.hover(position)
|
||||
case "documentSymbol":
|
||||
return lsp.documentSymbol(uri)
|
||||
case "workspaceSymbol":
|
||||
return lsp.workspaceSymbol("")
|
||||
case "goToImplementation":
|
||||
return lsp.implementation(position)
|
||||
case "prepareCallHierarchy":
|
||||
return lsp.prepareCallHierarchy(position)
|
||||
case "incomingCalls":
|
||||
return lsp.incomingCalls(position)
|
||||
case "outgoingCalls":
|
||||
return lsp.outgoingCalls(position)
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
title,
|
||||
metadata: { result },
|
||||
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
76
packages/opencode/src/tool/mcp-exa.ts
Normal file
76
packages/opencode/src/tool/mcp-exa.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
||||
const URL = "https://mcp.exa.ai/mcp"
|
||||
|
||||
const McpResult = Schema.Struct({
|
||||
result: Schema.Struct({
|
||||
content: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
text: Schema.String,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult))
|
||||
|
||||
const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) {
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
const data = yield* decode(line.substring(6))
|
||||
if (data.result.content[0]?.text) return data.result.content[0].text
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
export const SearchArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.String,
|
||||
numResults: Schema.Number,
|
||||
livecrawl: Schema.String,
|
||||
contextMaxCharacters: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
export const CodeArgs = Schema.Struct({
|
||||
query: Schema.String,
|
||||
tokensNum: Schema.Number,
|
||||
})
|
||||
|
||||
const McpRequest = <F extends Schema.Struct.Fields>(args: Schema.Struct<F>) =>
|
||||
Schema.Struct({
|
||||
jsonrpc: Schema.Literal("2.0"),
|
||||
id: Schema.Literal(1),
|
||||
method: Schema.Literal("tools/call"),
|
||||
params: Schema.Struct({
|
||||
name: Schema.String,
|
||||
arguments: args,
|
||||
}),
|
||||
})
|
||||
|
||||
export const call = <F extends Schema.Struct.Fields>(
|
||||
http: HttpClient.HttpClient,
|
||||
tool: string,
|
||||
args: Schema.Struct<F>,
|
||||
value: Schema.Struct.Type<F>,
|
||||
timeout: Duration.Input,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.post(URL).pipe(
|
||||
HttpClientRequest.accept("application/json, text/event-stream"),
|
||||
HttpClientRequest.schemaBodyJson(McpRequest(args))({
|
||||
jsonrpc: "2.0" as const,
|
||||
id: 1 as const,
|
||||
method: "tools/call" as const,
|
||||
params: { name: tool, arguments: value },
|
||||
}),
|
||||
)
|
||||
const response = yield* HttpClient.filterStatusOk(http)
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }),
|
||||
)
|
||||
const body = yield* response.text
|
||||
return yield* parseSse(body)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { Question } from "../question"
|
||||
import { Session } from "../session"
|
||||
@@ -9,123 +10,71 @@ import { Instance } from "../project/instance"
|
||||
import { type SessionID, MessageID, PartID } from "../session/schema"
|
||||
import EXIT_DESCRIPTION from "./plan-exit.txt"
|
||||
|
||||
async function getLastModel(sessionID: SessionID) {
|
||||
for await (const item of MessageV2.stream(sessionID)) {
|
||||
function getLastModel(sessionID: SessionID) {
|
||||
for (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
return Provider.defaultModel()
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PlanExitTool = Tool.define("plan_exit", {
|
||||
description: EXIT_DESCRIPTION,
|
||||
parameters: z.object({}),
|
||||
async execute(_params, ctx) {
|
||||
const session = await Session.get(ctx.sessionID)
|
||||
const plan = path.relative(Instance.worktree, Session.plan(session))
|
||||
const answers = await Question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
|
||||
header: "Build Agent",
|
||||
custom: false,
|
||||
options: [
|
||||
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
|
||||
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
const answer = answers[0]?.[0]
|
||||
if (answer === "No") throw new Question.RejectedError()
|
||||
|
||||
const model = await getLastModel(ctx.sessionID)
|
||||
|
||||
const userMsg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
agent: "build",
|
||||
model,
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "text",
|
||||
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
|
||||
synthetic: true,
|
||||
} satisfies MessageV2.TextPart)
|
||||
export const PlanExitTool = Tool.defineEffect(
|
||||
"plan_exit",
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const question = yield* Question.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
return {
|
||||
title: "Switching to build agent",
|
||||
output: "User approved switching to build agent. Wait for further instructions.",
|
||||
metadata: {},
|
||||
description: EXIT_DESCRIPTION,
|
||||
parameters: z.object({}),
|
||||
execute: (_params: {}, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* session.get(ctx.sessionID)
|
||||
const plan = path.relative(Instance.worktree, Session.plan(info))
|
||||
const answers = yield* question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
|
||||
header: "Build Agent",
|
||||
custom: false,
|
||||
options: [
|
||||
{ label: "Yes", description: "Switch to build agent and start implementing the plan" },
|
||||
{ label: "No", description: "Stay with plan agent to continue refining the plan" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
if (answers[0]?.[0] === "No") yield* new Question.RejectedError()
|
||||
|
||||
const model = getLastModel(ctx.sessionID) ?? (yield* provider.defaultModel())
|
||||
|
||||
const msg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model,
|
||||
}
|
||||
yield* session.updateMessage(msg)
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: msg.id,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "text",
|
||||
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
|
||||
synthetic: true,
|
||||
} satisfies MessageV2.TextPart)
|
||||
|
||||
return {
|
||||
title: "Switching to build agent",
|
||||
output: "User approved switching to build agent. Wait for further instructions.",
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
export const PlanEnterTool = Tool.define("plan_enter", {
|
||||
description: ENTER_DESCRIPTION,
|
||||
parameters: z.object({}),
|
||||
async execute(_params, ctx) {
|
||||
const session = await Session.get(ctx.sessionID)
|
||||
const plan = path.relative(Instance.worktree, Session.plan(session))
|
||||
|
||||
const answers = await Question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
|
||||
header: "Plan Mode",
|
||||
custom: false,
|
||||
options: [
|
||||
{ label: "Yes", description: "Switch to plan agent for research and planning" },
|
||||
{ label: "No", description: "Stay with build agent to continue making changes" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
const answer = answers[0]?.[0]
|
||||
|
||||
if (answer === "No") throw new Question.RejectedError()
|
||||
|
||||
const model = await getLastModel(ctx.sessionID)
|
||||
|
||||
const userMsg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
agent: "plan",
|
||||
model,
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: ctx.sessionID,
|
||||
type: "text",
|
||||
text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
|
||||
synthetic: true,
|
||||
} satisfies MessageV2.TextPart)
|
||||
|
||||
return {
|
||||
title: "Switching to plan agent",
|
||||
output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
|
||||
metadata: {},
|
||||
}
|
||||
},
|
||||
})
|
||||
*/
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||
const answers = await question
|
||||
.ask({
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const answers = yield* question.ask({
|
||||
sessionID: ctx.sessionID,
|
||||
questions: params.questions,
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
const formatted = params.questions
|
||||
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
|
||||
.join(", ")
|
||||
const formatted = params.questions
|
||||
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
|
||||
.join(", ")
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||
metadata: {
|
||||
answers,
|
||||
},
|
||||
}
|
||||
},
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
|
||||
metadata: {
|
||||
answers,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PlanExitTool } from "./plan"
|
||||
import { Session } from "../session"
|
||||
import { QuestionTool } from "./question"
|
||||
import { BashTool } from "./bash"
|
||||
import { EditTool } from "./edit"
|
||||
@@ -16,6 +17,7 @@ import { Config } from "../config/config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import z from "zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { CodeSearchTool } from "./codesearch"
|
||||
@@ -28,6 +30,9 @@ import { Glob } from "../util/glob"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
@@ -76,10 +81,14 @@ export namespace ToolRegistry {
|
||||
| Todo.Service
|
||||
| Agent.Service
|
||||
| Skill.Service
|
||||
| Session.Service
|
||||
| Provider.Service
|
||||
| LSP.Service
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -92,6 +101,12 @@ export namespace ToolRegistry {
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
const todo = yield* TodoWriteTool
|
||||
const lsptool = yield* LspTool
|
||||
const plan = yield* PlanExitTool
|
||||
const webfetch = yield* WebFetchTool
|
||||
const websearch = yield* WebSearchTool
|
||||
const bash = yield* BashTool
|
||||
const codesearch = yield* CodeSearchTool
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -150,22 +165,22 @@ export namespace ToolRegistry {
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(InvalidTool),
|
||||
bash: Tool.init(BashTool),
|
||||
bash: Tool.init(bash),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(GlobTool),
|
||||
grep: Tool.init(GrepTool),
|
||||
edit: Tool.init(EditTool),
|
||||
write: Tool.init(WriteTool),
|
||||
task: Tool.init(task),
|
||||
fetch: Tool.init(WebFetchTool),
|
||||
fetch: Tool.init(webfetch),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(WebSearchTool),
|
||||
code: Tool.init(CodeSearchTool),
|
||||
search: Tool.init(websearch),
|
||||
code: Tool.init(codesearch),
|
||||
skill: Tool.init(SkillTool),
|
||||
patch: Tool.init(ApplyPatchTool),
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(LspTool),
|
||||
plan: Tool.init(PlanExitTool),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -297,10 +312,14 @@ export namespace ToolRegistry {
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,170 +1,163 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
|
||||
export const WebFetchTool = Tool.define("webfetch", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.default("markdown")
|
||||
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
// Validate URL
|
||||
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
await ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
})
|
||||
|
||||
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||
|
||||
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
|
||||
|
||||
// Build Accept header based on requested format with q parameters for fallbacks
|
||||
let acceptHeader = "*/*"
|
||||
switch (params.format) {
|
||||
case "markdown":
|
||||
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
break
|
||||
case "text":
|
||||
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
|
||||
break
|
||||
case "html":
|
||||
acceptHeader = "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
break
|
||||
default:
|
||||
acceptHeader =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
}
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
Accept: acceptHeader,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
const response = await iife(async () => {
|
||||
try {
|
||||
const initial = await fetch(params.url, { signal, headers })
|
||||
|
||||
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
|
||||
return initial.status === 403 && initial.headers.get("cf-mitigated") === "challenge"
|
||||
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
|
||||
: initial
|
||||
} finally {
|
||||
clearTimeout()
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status code: ${response.status}`)
|
||||
}
|
||||
|
||||
// Check content length
|
||||
const contentLength = response.headers.get("content-length")
|
||||
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || ""
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
|
||||
const title = `${params.url} (${contentType})`
|
||||
|
||||
// Check if response is an image
|
||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
|
||||
if (isImage) {
|
||||
const base64Content = Buffer.from(arrayBuffer).toString("base64")
|
||||
return {
|
||||
title,
|
||||
output: "Image fetched successfully",
|
||||
metadata: {},
|
||||
attachments: [
|
||||
{
|
||||
type: "file",
|
||||
mime,
|
||||
url: `data:${mime};base64,${base64Content}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const content = new TextDecoder().decode(arrayBuffer)
|
||||
|
||||
// Handle content based on requested format and actual content type
|
||||
switch (params.format) {
|
||||
case "markdown":
|
||||
if (contentType.includes("text/html")) {
|
||||
const markdown = convertHTMLToMarkdown(content)
|
||||
return {
|
||||
output: markdown,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: content,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "text":
|
||||
if (contentType.includes("text/html")) {
|
||||
const text = await extractTextFromHTML(content)
|
||||
return {
|
||||
output: text,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: content,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
case "html":
|
||||
return {
|
||||
output: content,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
output: content,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
},
|
||||
const parameters = z.object({
|
||||
url: z.string().describe("The URL to fetch content from"),
|
||||
format: z
|
||||
.enum(["text", "markdown", "html"])
|
||||
.default("markdown")
|
||||
.describe("The format to return the content in (text, markdown, or html). Defaults to markdown."),
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
})
|
||||
|
||||
export const WebFetchTool = Tool.defineEffect(
|
||||
"webfetch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||
|
||||
// Build Accept header based on requested format with q parameters for fallbacks
|
||||
let acceptHeader = "*/*"
|
||||
switch (params.format) {
|
||||
case "markdown":
|
||||
acceptHeader = "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1"
|
||||
break
|
||||
case "text":
|
||||
acceptHeader = "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1"
|
||||
break
|
||||
case "html":
|
||||
acceptHeader =
|
||||
"text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
break
|
||||
default:
|
||||
acceptHeader =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
}
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
Accept: acceptHeader,
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
}
|
||||
|
||||
const request = HttpClientRequest.get(params.url).pipe(HttpClientRequest.setHeaders(headers))
|
||||
|
||||
// Retry with honest UA if blocked by Cloudflare bot detection (TLS fingerprint mismatch)
|
||||
const response = yield* httpOk.execute(request).pipe(
|
||||
Effect.catchIf(
|
||||
(err) =>
|
||||
err.reason._tag === "StatusCodeError" &&
|
||||
err.reason.response.status === 403 &&
|
||||
err.reason.response.headers["cf-mitigated"] === "challenge",
|
||||
() =>
|
||||
httpOk.execute(
|
||||
HttpClientRequest.get(params.url).pipe(
|
||||
HttpClientRequest.setHeaders({ ...headers, "User-Agent": "opencode" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error("Request timed out")) }),
|
||||
)
|
||||
|
||||
// Check content length
|
||||
const contentLength = response.headers["content-length"]
|
||||
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const arrayBuffer = yield* response.arrayBuffer
|
||||
if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) {
|
||||
throw new Error("Response too large (exceeds 5MB limit)")
|
||||
}
|
||||
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
|
||||
const title = `${params.url} (${contentType})`
|
||||
|
||||
// Check if response is an image
|
||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||
|
||||
if (isImage) {
|
||||
const base64Content = Buffer.from(arrayBuffer).toString("base64")
|
||||
return {
|
||||
title,
|
||||
output: "Image fetched successfully",
|
||||
metadata: {},
|
||||
attachments: [
|
||||
{
|
||||
type: "file" as const,
|
||||
mime,
|
||||
url: `data:${mime};base64,${base64Content}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const content = new TextDecoder().decode(arrayBuffer)
|
||||
|
||||
// Handle content based on requested format and actual content type
|
||||
switch (params.format) {
|
||||
case "markdown":
|
||||
if (contentType.includes("text/html")) {
|
||||
const markdown = convertHTMLToMarkdown(content)
|
||||
return {
|
||||
output: markdown,
|
||||
title,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return { output: content, title, metadata: {} }
|
||||
|
||||
case "text":
|
||||
if (contentType.includes("text/html")) {
|
||||
const text = yield* Effect.promise(() => extractTextFromHTML(content))
|
||||
return { output: text, title, metadata: {} }
|
||||
}
|
||||
return { output: content, title, metadata: {} }
|
||||
|
||||
case "html":
|
||||
return { output: content, title, metadata: {} }
|
||||
|
||||
default:
|
||||
return { output: content, title, metadata: {} }
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
async function extractTextFromHTML(html: string) {
|
||||
let text = ""
|
||||
let skipContent = false
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Tool } from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
ENDPOINTS: {
|
||||
SEARCH: "/mcp",
|
||||
},
|
||||
DEFAULT_NUM_RESULTS: 8,
|
||||
} as const
|
||||
|
||||
const Parameters = z.object({
|
||||
query: z.string().describe("Websearch query"),
|
||||
@@ -30,121 +24,53 @@ const Parameters = z.object({
|
||||
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
|
||||
})
|
||||
|
||||
interface McpSearchRequest {
|
||||
jsonrpc: string
|
||||
id: number
|
||||
method: string
|
||||
params: {
|
||||
name: string
|
||||
arguments: {
|
||||
query: string
|
||||
numResults?: number
|
||||
livecrawl?: "fallback" | "preferred"
|
||||
type?: "auto" | "fast" | "deep"
|
||||
contextMaxCharacters?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
export const WebSearchTool = Tool.defineEffect(
|
||||
"websearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
interface McpSearchResponse {
|
||||
jsonrpc: string
|
||||
result: {
|
||||
content: Array<{
|
||||
type: string
|
||||
text: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
return {
|
||||
get description() {
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "websearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
numResults: params.numResults,
|
||||
livecrawl: params.livecrawl,
|
||||
type: params.type,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
return {
|
||||
get description() {
|
||||
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
|
||||
},
|
||||
parameters: Parameters,
|
||||
async execute(params, ctx) {
|
||||
await ctx.ask({
|
||||
permission: "websearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
numResults: params.numResults,
|
||||
livecrawl: params.livecrawl,
|
||||
type: params.type,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
})
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
"web_search_exa",
|
||||
McpExa.SearchArgs,
|
||||
{
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || 8,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
"25 seconds",
|
||||
)
|
||||
|
||||
const searchRequest: McpSearchRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_exa",
|
||||
arguments: {
|
||||
query: params.query,
|
||||
type: params.type || "auto",
|
||||
numResults: params.numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
|
||||
livecrawl: params.livecrawl || "fallback",
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(searchRequest),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Search error (${response.status}): ${errorText}`)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
// Parse SSE response
|
||||
const lines = responseText.split("\n")
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data: McpSearchResponse = JSON.parse(line.substring(6))
|
||||
if (data.result && data.result.content && data.result.content.length > 0) {
|
||||
return {
|
||||
output: data.result.content[0].text,
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
return {
|
||||
output: result ?? "No search results found. Please try a different query.",
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output: "No search results found. Please try a different query.",
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Search request timed out")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -246,6 +246,7 @@ export namespace Worktree {
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const workspaceID = yield* InstanceState.workspaceID
|
||||
const projectID = ctx.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
@@ -255,6 +256,8 @@ export namespace Worktree {
|
||||
log.error("worktree checkout failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return
|
||||
@@ -272,6 +275,8 @@ export namespace Worktree {
|
||||
log.error("worktree bootstrap failed", { directory: info.directory, message })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
})
|
||||
return false
|
||||
@@ -281,6 +286,8 @@ export namespace Worktree {
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
project: ctx.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: { name: info.name, branch: info.branch },
|
||||
|
||||
293
packages/opencode/test/cli/tui/sync-provider.test.tsx
Normal file
293
packages/opencode/test/cli/tui/sync-provider.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
|
||||
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
|
||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
|
||||
|
||||
const sighup = new Set(process.listeners("SIGHUP"))
|
||||
|
||||
afterEach(() => {
|
||||
for (const fn of process.listeners("SIGHUP")) {
|
||||
if (!sighup.has(fn)) process.off("SIGHUP", fn)
|
||||
}
|
||||
})
|
||||
|
||||
function json(data: unknown) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function data(workspace?: string | null) {
|
||||
const tag = workspace ?? "root"
|
||||
return {
|
||||
session: {
|
||||
id: "ses_1",
|
||||
title: `session-${tag}`,
|
||||
workspaceID: workspace ?? undefined,
|
||||
time: {
|
||||
updated: 1,
|
||||
},
|
||||
},
|
||||
message: {
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: 1,
|
||||
completed: 1,
|
||||
},
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "part_1",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "text",
|
||||
text: `part-${tag}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
todo: [
|
||||
{
|
||||
id: `todo-${tag}`,
|
||||
content: `todo-${tag}`,
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
},
|
||||
],
|
||||
diff: [
|
||||
{
|
||||
file: `${tag}.ts`,
|
||||
patch: "",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
type Hit = {
|
||||
path: string
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
function createFetch(log: Hit[]) {
|
||||
return Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = new Request(input, init)
|
||||
const url = new URL(req.url)
|
||||
const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
|
||||
log.push({
|
||||
path: url.pathname,
|
||||
workspace,
|
||||
})
|
||||
|
||||
if (url.pathname === "/config/providers") {
|
||||
return json({ providers: [], default: {} })
|
||||
}
|
||||
if (url.pathname === "/provider") {
|
||||
return json({ all: [], default: {}, connected: [] })
|
||||
}
|
||||
if (url.pathname === "/experimental/console") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/agent") {
|
||||
return json([])
|
||||
}
|
||||
if (url.pathname === "/config") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/project/current") {
|
||||
return json({ id: `proj-${workspace ?? "root"}` })
|
||||
}
|
||||
if (url.pathname === "/path") {
|
||||
return json({
|
||||
state: `/tmp/${workspace ?? "root"}/state`,
|
||||
config: `/tmp/${workspace ?? "root"}/config`,
|
||||
worktree: "/tmp/worktree",
|
||||
directory: `/tmp/${workspace ?? "root"}`,
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/session") {
|
||||
return json([])
|
||||
}
|
||||
if (url.pathname === "/command") {
|
||||
return json([])
|
||||
}
|
||||
if (url.pathname === "/lsp") {
|
||||
return json([])
|
||||
}
|
||||
if (url.pathname === "/mcp") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/experimental/resource") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/formatter") {
|
||||
return json([])
|
||||
}
|
||||
if (url.pathname === "/session/status") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/provider/auth") {
|
||||
return json({})
|
||||
}
|
||||
if (url.pathname === "/vcs") {
|
||||
return json({ branch: "main" })
|
||||
}
|
||||
if (url.pathname === "/experimental/workspace") {
|
||||
return json([{ id: "ws_a" }, { id: "ws_b" }])
|
||||
}
|
||||
if (url.pathname === "/session/ses_1") {
|
||||
return json(data(workspace).session)
|
||||
}
|
||||
if (url.pathname === "/session/ses_1/message") {
|
||||
return json([data(workspace).message])
|
||||
}
|
||||
if (url.pathname === "/session/ses_1/todo") {
|
||||
return json(data(workspace).todo)
|
||||
}
|
||||
if (url.pathname === "/session/ses_1/diff") {
|
||||
return json(data(workspace).diff)
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
|
||||
},
|
||||
{ preconnect: fetch.preconnect.bind(fetch) },
|
||||
) satisfies typeof fetch
|
||||
}
|
||||
|
||||
async function mount(log: Hit[]) {
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider
|
||||
url="http://test"
|
||||
directory="/tmp/root"
|
||||
fetch={createFetch(log)}
|
||||
events={{ subscribe: async () => () => {} }}
|
||||
>
|
||||
<ArgsProvider continue={false}>
|
||||
<ExitProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<Probe
|
||||
onReady={(ctx) => {
|
||||
project = ctx.project
|
||||
sync = ctx.sync
|
||||
done()
|
||||
}}
|
||||
/>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
await ready
|
||||
return { app, project, sync }
|
||||
}
|
||||
|
||||
async function waitBoot(log: Hit[], workspace?: string) {
|
||||
await wait(() => log.some((item) => item.path === "/experimental/workspace"))
|
||||
if (!workspace) return
|
||||
await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
|
||||
}) {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
|
||||
onMount(() => {
|
||||
props.onReady({ project, sync })
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
describe("SyncProvider", () => {
|
||||
test("re-runs bootstrap requests when the active workspace changes", async () => {
|
||||
const log: Hit[] = []
|
||||
const { app, project } = await mount(log)
|
||||
|
||||
try {
|
||||
await waitBoot(log)
|
||||
log.length = 0
|
||||
|
||||
project.workspace.set("ws_a")
|
||||
|
||||
await waitBoot(log, "ws_a")
|
||||
|
||||
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
|
||||
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
|
||||
expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true)
|
||||
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("clears full-sync cache when the active workspace changes", async () => {
|
||||
const log: Hit[] = []
|
||||
const { app, project, sync } = await mount(log)
|
||||
|
||||
try {
|
||||
await waitBoot(log)
|
||||
|
||||
log.length = 0
|
||||
project.workspace.set("ws_a")
|
||||
await waitBoot(log, "ws_a")
|
||||
expect(project.workspace.current()).toBe("ws_a")
|
||||
|
||||
log.length = 0
|
||||
await sync.session.sync("ses_1")
|
||||
|
||||
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
|
||||
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
|
||||
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
|
||||
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
|
||||
|
||||
log.length = 0
|
||||
project.workspace.set("ws_b")
|
||||
await waitBoot(log, "ws_b")
|
||||
expect(project.workspace.current()).toBe("ws_b")
|
||||
|
||||
log.length = 0
|
||||
await sync.session.sync("ses_1")
|
||||
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
|
||||
|
||||
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
|
||||
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
|
||||
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
|
||||
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
175
packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
175
packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
|
||||
return {
|
||||
directory: input.directory,
|
||||
workspace: input.workspace,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
return {
|
||||
type: "vcs.branch.updated",
|
||||
properties: {
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function update(version: string): Event {
|
||||
return {
|
||||
type: "installation.update-available",
|
||||
properties: {
|
||||
version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSource() {
|
||||
let fn: ((event: GlobalEvent) => void) | undefined
|
||||
|
||||
return {
|
||||
source: {
|
||||
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
||||
fn = handler
|
||||
return () => {
|
||||
if (fn === handler) fn = undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
emit(evt: GlobalEvent) {
|
||||
if (!fn) throw new Error("event source not ready")
|
||||
fn(evt)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const source = createSource()
|
||||
const seen: Event[] = []
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={(ctx) => {
|
||||
project = ctx.project
|
||||
done()
|
||||
}}
|
||||
seen={seen}
|
||||
/>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
await ready
|
||||
return { app, emit: source.emit, project, seen }
|
||||
}
|
||||
|
||||
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
|
||||
const project = useProject()
|
||||
const event = useEvent()
|
||||
|
||||
onMount(() => {
|
||||
event.subscribe((evt) => {
|
||||
props.seen.push(evt)
|
||||
})
|
||||
props.onReady({ project })
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
describe("useEvent", () => {
|
||||
test("delivers matching directory events without an active workspace", async () => {
|
||||
const { app, emit, seen } = await mount()
|
||||
|
||||
try {
|
||||
emit(event(vcs("main"), { directory: "/tmp/root" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("main")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores non-matching directory events without an active workspace", async () => {
|
||||
const { app, emit, seen } = await mount()
|
||||
|
||||
try {
|
||||
emit(event(vcs("other"), { directory: "/tmp/other" }))
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(seen).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers matching workspace events when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("ws")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores non-matching workspace events when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(seen).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers truly global events even when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(update("1.2.3"), { directory: "global" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([update("1.2.3")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
fn: async () => {
|
||||
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(watcherConfigLayer),
|
||||
)
|
||||
const rt = ManagedRuntime.make(layer)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import * as Lsp from "../../src/lsp/index"
|
||||
import * as launch from "../../src/lsp/launch"
|
||||
import { LSPServer } from "../../src/lsp/server"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -54,80 +52,4 @@ describe("lsp.spawn", () => {
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
test("spawns builtin Typescript LSP with correct arguments", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
// Create dummy tsserver to satisfy Module.resolve
|
||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||
await fs.mkdir(tsdk, { recursive: true })
|
||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||
|
||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdin: {},
|
||||
stdout: {},
|
||||
stderr: {},
|
||||
on: () => {},
|
||||
kill: () => {},
|
||||
}) as any,
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await LSPServer.Typescript.spawn(tmp.path)
|
||||
},
|
||||
})
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalled()
|
||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||
|
||||
expect(args).toContain("--tsserver-path")
|
||||
expect(args).toContain("--tsserver-log-verbosity")
|
||||
expect(args).toContain("off")
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
// Create dummy tsserver to satisfy Module.resolve
|
||||
const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib")
|
||||
await fs.mkdir(tsdk, { recursive: true })
|
||||
await fs.writeFile(path.join(tsdk, "tsserver.js"), "")
|
||||
|
||||
// NO tsconfig.json or jsconfig.json created here
|
||||
|
||||
const spawnSpy = spyOn(launch, "spawn").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
stdin: {},
|
||||
stdout: {},
|
||||
stderr: {},
|
||||
on: () => {},
|
||||
kill: () => {},
|
||||
}) as any,
|
||||
)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await LSPServer.Typescript.spawn(tmp.path)
|
||||
},
|
||||
})
|
||||
|
||||
expect(spawnSpy).toHaveBeenCalled()
|
||||
const args = spawnSpy.mock.calls[0][1] as string[]
|
||||
|
||||
expect(args).toContain("--ignore-node-modules")
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
@@ -30,7 +32,11 @@ describe("memory: abort controller leak", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.init()
|
||||
const tool = await WebFetchTool.pipe(
|
||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
// Warm up
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
@@ -169,6 +170,7 @@ function makeHttp() {
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* tools internally during multi-step processing before emitting events.
|
||||
*/
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Session } from "../../src/session"
|
||||
@@ -28,7 +29,6 @@ import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
// Same layer setup as prompt-effect.test.ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Layer } from "effect"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
@@ -134,6 +134,7 @@ function makeHttp() {
|
||||
const todo = Todo.layer.pipe(Layer.provideMerge(deps))
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "../../src/global"
|
||||
import { Storage } from "../../src/storage/storage"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -47,7 +48,7 @@ async function withStorage<T>(
|
||||
root: string,
|
||||
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
|
||||
) {
|
||||
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root))))
|
||||
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer)))
|
||||
try {
|
||||
return await fn((body) => rt.runPromise(body))
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
@@ -9,6 +10,15 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import type { Permission } from "../../src/permission"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
function initBash() {
|
||||
return BashTool.pipe(
|
||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
||||
Effect.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Effect.runPromise,
|
||||
)
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -118,7 +128,7 @@ describe("tool.bash", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
@@ -139,7 +149,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -160,7 +170,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -184,7 +194,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -208,7 +218,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
||||
@@ -242,7 +252,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
@@ -273,7 +283,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -301,7 +311,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
|
||||
await bash.execute(
|
||||
@@ -331,7 +341,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -359,7 +369,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -388,7 +398,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -416,7 +426,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -448,7 +458,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
@@ -481,7 +491,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -508,7 +518,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -538,7 +548,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -568,7 +578,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -597,7 +607,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -622,7 +632,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -645,7 +655,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -673,7 +683,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
|
||||
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
@@ -707,7 +717,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
@@ -737,7 +747,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
@@ -772,7 +782,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
@@ -803,7 +813,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -823,7 +833,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -844,7 +854,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
@@ -864,7 +874,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
@@ -885,7 +895,7 @@ describe("tool.bash permissions", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -901,7 +911,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const controller = new AbortController()
|
||||
const collected: string[] = []
|
||||
const result = bash.execute(
|
||||
@@ -933,7 +943,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
@@ -952,7 +962,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
@@ -971,7 +981,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
@@ -988,7 +998,7 @@ describe("tool.bash abort", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const updates: string[] = []
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1016,7 +1026,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1036,7 +1046,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await bash.execute(
|
||||
{
|
||||
@@ -1056,7 +1066,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
@@ -1074,7 +1084,7 @@ describe("tool.bash truncation", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await BashTool.init()
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await bash.execute(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
@@ -22,6 +24,14 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
|
||||
await fn(server.url)
|
||||
}
|
||||
|
||||
function initTool() {
|
||||
return WebFetchTool.pipe(
|
||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.runPromise,
|
||||
)
|
||||
}
|
||||
|
||||
describe("tool.webfetch", () => {
|
||||
test("returns image responses as file attachments", async () => {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
@@ -31,7 +41,7 @@ describe("tool.webfetch", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await WebFetchTool.init()
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute(
|
||||
{ url: new URL("/image.png", url).toString(), format: "markdown" },
|
||||
ctx,
|
||||
@@ -63,7 +73,7 @@ describe("tool.webfetch", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await WebFetchTool.init()
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
@@ -84,7 +94,7 @@ describe("tool.webfetch", () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await WebFetchTool.init()
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
|
||||
@@ -1011,6 +1011,8 @@ export type Event =
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: Event
|
||||
}
|
||||
|
||||
|
||||
@@ -9926,6 +9926,12 @@
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"project": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/components/schemas/Event"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user