mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-15 18:34:48 +00:00
Compare commits
1 Commits
foo
...
kit/e2e-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
457a4b26fe |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -25,7 +25,6 @@ kommander
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_workspace` (
|
||||
`id` text PRIMARY KEY,
|
||||
`type` text NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`branch` text,
|
||||
`directory` text,
|
||||
`extra` text,
|
||||
`project_id` text NOT NULL,
|
||||
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
|
||||
DROP TABLE `workspace`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,20 +18,14 @@ const seed = async () => {
|
||||
const { Project } = await import("../src/project/project")
|
||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||
const { ToolRegistry } = await import("../src/tool/registry")
|
||||
const { Effect } = await import("effect")
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
await Config.waitForDependencies()
|
||||
await AppRuntime.runPromise(ToolRegistry.Service.use((svc) => svc.ids()))
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
@@ -88,4 +89,22 @@ export namespace Auth {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
}
|
||||
|
||||
export async function all(): Promise<Record<string, Info>> {
|
||||
return runPromise((service) => service.all())
|
||||
}
|
||||
|
||||
export async function set(key: string, info: Info) {
|
||||
return runPromise((service) => service.set(key, info))
|
||||
}
|
||||
|
||||
export async function remove(key: string) {
|
||||
return runPromise((service) => service.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
@@ -72,17 +71,11 @@ export const AgentCommand = cmd({
|
||||
})
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
@@ -125,14 +118,7 @@ function parseToolParams(input?: string) {
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model =
|
||||
agent.model ??
|
||||
(await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
}),
|
||||
))
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -10,7 +9,7 @@ export const ConfigCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -17,11 +15,7 @@ const FileSearchCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
}),
|
||||
)
|
||||
const results = await File.search({ query: args.query })
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -38,11 +32,7 @@ const FileReadCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.read(args.path))
|
||||
}),
|
||||
)
|
||||
const content = await File.read(args.path)
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -54,11 +44,7 @@ const FileStatusCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.status())
|
||||
}),
|
||||
)
|
||||
const status = await File.status()
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -75,11 +61,7 @@ const FileListCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.list(args.path))
|
||||
}),
|
||||
)
|
||||
const files = await File.list(args.path)
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { LSP } from "../../../lsp"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
@@ -21,16 +19,9 @@ const DiagnosticsCommand = cmd({
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, true)
|
||||
yield* Effect.sleep(1000)
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
await LSP.touchFile(args.file, true)
|
||||
await sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -42,7 +33,7 @@ export const SymbolsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -55,7 +46,7 @@ export const DocumentSymbolsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Skill } from "../../../skill"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -11,12 +9,7 @@ export const SkillCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
const skills = await Skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -15,8 +15,6 @@ import { Global } from "../../global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
@@ -52,47 +50,6 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
return isMcpConfigured(config) && config.type === "remote"
|
||||
}
|
||||
|
||||
function configuredServers(config: Config.Info) {
|
||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||
}
|
||||
|
||||
function oauthServers(config: Config.Info) {
|
||||
return configuredServers(config).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
}
|
||||
|
||||
async function listState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const statuses = yield* mcp.status()
|
||||
const stored = yield* Effect.all(
|
||||
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, statuses, stored }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function authState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const auth = yield* Effect.all(
|
||||
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, auth }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const McpCommand = cmd({
|
||||
command: "mcp",
|
||||
describe: "manage MCP (Model Context Protocol) servers",
|
||||
@@ -118,8 +75,13 @@ export const McpListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const { config, statuses, stored } = await listState()
|
||||
const servers = configuredServers(config)
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const statuses = await MCP.status()
|
||||
|
||||
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
|
||||
isMcpConfigured(entry[1]),
|
||||
)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
@@ -130,7 +92,7 @@ export const McpListCommand = cmd({
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
const hasStoredTokens = await MCP.hasStoredTokens(name)
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
@@ -190,11 +152,15 @@ export const McpAuthCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const { config, auth } = await authState()
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||
prompts.log.info(`
|
||||
@@ -211,17 +177,19 @@ export const McpAuthCommand = cmd({
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
})
|
||||
const options = await Promise.all(
|
||||
oauthServers.map(async ([name, cfg]) => {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const selected = await prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
@@ -245,8 +213,7 @@ export const McpAuthCommand = cmd({
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatus =
|
||||
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
|
||||
const authStatus = await MCP.getAuthStatus(serverName)
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = await prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
@@ -273,7 +240,7 @@ export const McpAuthCommand = cmd({
|
||||
})
|
||||
|
||||
try {
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
|
||||
const status = await MCP.authenticate(serverName)
|
||||
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
@@ -322,17 +289,22 @@ export const McpAuthListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const { config, auth } = await authState()
|
||||
const servers = oauthServers(config)
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
|
||||
if (servers.length === 0) {
|
||||
// Get OAuth-capable servers
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
for (const [name, serverConfig] of oauthServers) {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
@@ -340,7 +312,7 @@ export const McpAuthListCommand = cmd({
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -362,7 +334,7 @@ export const McpLogoutCommand = cmd({
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const authPath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
|
||||
const credentials = await McpAuth.all()
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
@@ -400,7 +372,7 @@ export const McpLogoutCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
|
||||
await MCP.removeAuth(serverName)
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
},
|
||||
@@ -623,7 +595,7 @@ export const McpDebugCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
|
||||
@@ -650,18 +622,10 @@ export const McpDebugCommand = cmd({
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// Check stored auth status
|
||||
const { authStatus, entry } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
return {
|
||||
authStatus: yield* mcp.getAuthStatus(serverName),
|
||||
entry: yield* auth.get(serverName),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const authStatus = await MCP.getAuthStatus(serverName)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
const entry = await McpAuth.get(serverName)
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import { ModelsDev } from "../../provider/models"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "../ui"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ModelsCommand = cmd({
|
||||
command: "models [provider]",
|
||||
@@ -37,51 +35,43 @@ export const ModelsCommand = cmd({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
const providers = await Provider.list()
|
||||
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const provider = providers[providerID]
|
||||
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
function printModels(providerID: ProviderID, verbose?: boolean) {
|
||||
const provider = providers[providerID]
|
||||
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sortedModels) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
const provider = providers[providerID]
|
||||
if (!provider) {
|
||||
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||
return
|
||||
}
|
||||
if (args.provider) {
|
||||
const provider = providers[ProviderID.make(args.provider)]
|
||||
if (!provider) {
|
||||
UI.error(`Provider not found: ${args.provider}`)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||
return
|
||||
}
|
||||
printModels(ProviderID.make(args.provider), args.verbose)
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const providerIDs = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
for (const providerID of ids) {
|
||||
print(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
for (const providerID of providerIDs) {
|
||||
printModels(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
@@ -14,18 +13,9 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
const put = (key: string, info: Auth.Info) =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(key, info)
|
||||
}),
|
||||
)
|
||||
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
@@ -103,7 +93,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -112,7 +102,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -135,7 +125,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -144,7 +134,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -171,7 +161,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
})
|
||||
@@ -231,12 +221,7 @@ export const ProvidersListCommand = cmd({
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const results = Object.entries(await Auth.all())
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
@@ -315,7 +300,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await put(url, {
|
||||
await Auth.set(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
@@ -326,7 +311,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
}
|
||||
await ModelsDev.refresh(true).catch(() => {})
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
@@ -462,7 +447,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await put(provider, {
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
@@ -478,33 +463,22 @@ export const ProvidersLogoutCommand = cmd({
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const selected = await prompts.select({
|
||||
const providerID = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
||||
await Auth.remove(providerID)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,12 +9,6 @@ import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Adaptor = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
@@ -69,27 +63,9 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adaptor[]>)
|
||||
.catch(() => undefined)
|
||||
if (!res) {
|
||||
toast.show({
|
||||
message: "Failed to load workspace adaptors",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
setAdaptors(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -103,21 +79,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
},
|
||||
]
|
||||
}
|
||||
const list = adaptors()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adaptors",
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
@@ -145,7 +113,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
if (option.value === "creating") return
|
||||
void create(option.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -81,7 +81,7 @@ export const rpc = {
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Argv, InferredOptionTypes } from "yargs"
|
||||
import { Config } from "../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const options = {
|
||||
port: {
|
||||
@@ -38,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||
}
|
||||
|
||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
const config = await Config.getGlobal()
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
const config = await Config.getGlobal()
|
||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
||||
if (!latest) return
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ConfigPaths } from "./paths"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
|
||||
import { Flock } from "@/util/flock"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
@@ -1660,4 +1661,42 @@ export namespace Config {
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function getGlobal() {
|
||||
return runPromise((svc) => svc.getGlobal())
|
||||
}
|
||||
|
||||
export async function getConsoleState() {
|
||||
return runPromise((svc) => svc.getConsoleState())
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string, input?: InstallInput) {
|
||||
return runPromise((svc) => svc.installDependencies(dir, input))
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
return runPromise((svc) => svc.update(config))
|
||||
}
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
return runPromise((svc) => svc.updateGlobal(config))
|
||||
}
|
||||
|
||||
export async function invalidate(wait = false) {
|
||||
return runPromise((svc) => svc.invalidate(wait))
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
return runPromise((svc) => svc.directories())
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
return runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
@@ -52,7 +51,7 @@ export namespace TuiConfig {
|
||||
}
|
||||
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
|
||||
return Config.installDependencies(dir)
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdaptor } from "../types"
|
||||
import type { Adaptor } from "../types"
|
||||
|
||||
export type WorkspaceAdaptorEntry = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
|
||||
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
|
||||
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||
|
||||
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin()
|
||||
|
||||
throw new Error(`Unknown workspace adaptor: ${type}`)
|
||||
export function getAdaptor(type: string): Promise<Adaptor> {
|
||||
return ADAPTORS[type]()
|
||||
}
|
||||
|
||||
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, init]) => {
|
||||
const adaptor = await init()
|
||||
return {
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
export function installAdaptor(type: string, adaptor: Adaptor) {
|
||||
// This is experimental: mostly used for testing right now, but we
|
||||
// will likely allow this in the future. Need to figure out the
|
||||
// TypeScript story
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
|
||||
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
|
||||
adaptors.set(type, adaptor)
|
||||
state.set(projectID, adaptors)
|
||||
// @ts-expect-error we force the builtin types right now, but we
|
||||
// will implement a way to extend the types for custom adaptors
|
||||
ADAPTORS[type] = () => adaptor
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import z from "zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
import { type Adaptor, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = z.object({
|
||||
name: WorkspaceInfo.shape.name,
|
||||
const Config = WorkspaceInfo.extend({
|
||||
name: WorkspaceInfo.shape.name.unwrap(),
|
||||
branch: WorkspaceInfo.shape.branch.unwrap(),
|
||||
directory: WorkspaceInfo.shape.directory.unwrap(),
|
||||
})
|
||||
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
type Config = z.infer<typeof Config>
|
||||
|
||||
export const WorktreeAdaptor: Adaptor = {
|
||||
async configure(info) {
|
||||
const worktree = await Worktree.makeWorktreeInfo(undefined)
|
||||
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
|
||||
return {
|
||||
...info,
|
||||
name: worktree.name,
|
||||
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
}
|
||||
},
|
||||
async create(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
const config = Config.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
})
|
||||
},
|
||||
async remove(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
const config = Config.parse(info)
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
target(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
const config = Config.parse(info)
|
||||
return {
|
||||
type: "local",
|
||||
directory: config.directory,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
|
||||
export const WorkspaceInfo = z.object({
|
||||
id: WorkspaceID.zod,
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
branch: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
directory: z.string().nullable(),
|
||||
extra: z.unknown().nullable(),
|
||||
projectID: ProjectID.zod,
|
||||
@@ -24,11 +24,9 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
export type Adaptor = {
|
||||
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(config: WorkspaceInfo): Promise<void>
|
||||
target(config: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<WorkspaceID>().primaryKey(),
|
||||
type: text().notNull(),
|
||||
name: text().notNull().default(""),
|
||||
branch: text(),
|
||||
name: text(),
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SyncEvent } from "@/sync"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { WorkspaceInfo } from "./types"
|
||||
@@ -67,9 +66,9 @@ export namespace Workspace {
|
||||
|
||||
export const create = fn(CreateInput, async (input) => {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adaptor = await getAdaptor(input.projectID, input.type)
|
||||
const adaptor = await getAdaptor(input.type)
|
||||
|
||||
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
|
||||
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
@@ -125,7 +124,7 @@ export namespace Workspace {
|
||||
stopSync(id)
|
||||
|
||||
const info = fromRow(row)
|
||||
const adaptor = await getAdaptor(info.projectID, row.type)
|
||||
const adaptor = await getAdaptor(row.type)
|
||||
adaptor.remove(info)
|
||||
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||
return info
|
||||
@@ -163,7 +162,7 @@ export namespace Workspace {
|
||||
log.info("connecting to sync: " + space.id)
|
||||
|
||||
setStatus(space.id, "connecting")
|
||||
const adaptor = await getAdaptor(space.projectID, space.type)
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const target = await adaptor.target(space)
|
||||
|
||||
if (target.type === "local") return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
@@ -643,4 +644,26 @@ export namespace File {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromise((svc) => svc.status())
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromise((svc) => svc.read(file))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromise((svc) => svc.list(dir))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
@@ -94,40 +94,6 @@ export namespace Ripgrep {
|
||||
|
||||
const Result = z.union([Begin, Match, End, Summary])
|
||||
|
||||
const Hit = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: Schema.Number,
|
||||
absolute_offset: Schema.Number,
|
||||
submatches: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const Row = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
|
||||
Hit,
|
||||
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
|
||||
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
|
||||
])
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
|
||||
|
||||
export type Result = z.infer<typeof Result>
|
||||
export type Match = z.infer<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
@@ -423,19 +389,9 @@ export namespace Ripgrep {
|
||||
}),
|
||||
)
|
||||
|
||||
const [items, stderr, code] = yield* Effect.all(
|
||||
const [stdout, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect((line) =>
|
||||
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
|
||||
),
|
||||
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
|
||||
Stream.map((row): Item => row.data),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
),
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
@@ -446,6 +402,15 @@ export namespace Ripgrep {
|
||||
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
|
||||
}
|
||||
|
||||
const items = stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((parsed) => Result.parse(parsed))
|
||||
.filter((row): row is Match => row.type === "match")
|
||||
.map((row) => row.data)
|
||||
|
||||
return {
|
||||
items,
|
||||
partial: code === 2,
|
||||
|
||||
@@ -13,7 +13,6 @@ export namespace Identifier {
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
entry: "ent",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Process } from "../util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -507,6 +508,37 @@ export namespace LSP {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const init = async () => runPromise((svc) => svc.init())
|
||||
|
||||
export const status = async () => runPromise((svc) => svc.status())
|
||||
|
||||
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
|
||||
|
||||
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
|
||||
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
|
||||
|
||||
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
|
||||
|
||||
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
|
||||
|
||||
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
|
||||
|
||||
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
|
||||
|
||||
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
|
||||
|
||||
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
|
||||
|
||||
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
|
||||
|
||||
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
|
||||
|
||||
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
|
||||
|
||||
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
|
||||
|
||||
export namespace Diagnostic {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -889,4 +890,37 @@ export namespace MCP {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// --- Async facade functions ---
|
||||
|
||||
export const status = async () => runPromise((svc) => svc.status())
|
||||
|
||||
export const tools = async () => runPromise((svc) => svc.tools())
|
||||
|
||||
export const prompts = async () => runPromise((svc) => svc.prompts())
|
||||
|
||||
export const resources = async () => runPromise((svc) => svc.resources())
|
||||
|
||||
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
|
||||
|
||||
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
|
||||
|
||||
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
||||
|
||||
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
||||
|
||||
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
||||
|
||||
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
|
||||
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
|
||||
|
||||
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
|
||||
|
||||
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
|
||||
|
||||
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
|
||||
|
||||
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
Hooks,
|
||||
PluginInput,
|
||||
Plugin as PluginInstance,
|
||||
PluginModule,
|
||||
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
||||
} from "@opencode-ai/plugin"
|
||||
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
|
||||
import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
@@ -24,8 +18,6 @@ import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -140,11 +132,6 @@ export namespace Plugin {
|
||||
project: ctx.project,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
experimental_workspace: {
|
||||
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
||||
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
||||
},
|
||||
},
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
@@ -230,4 +231,22 @@ export namespace Vcs {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
|
||||
export async function defaultBranch() {
|
||||
return runPromise((svc) => svc.defaultBranch())
|
||||
}
|
||||
|
||||
export async function diff(mode: Mode) {
|
||||
return runPromise((svc) => svc.diff(mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import path from "path"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
@@ -1692,6 +1693,36 @@ export namespace Provider {
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function getProvider(providerID: ProviderID) {
|
||||
return runPromise((svc) => svc.getProvider(providerID))
|
||||
}
|
||||
|
||||
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||
return runPromise((svc) => svc.getModel(providerID, modelID))
|
||||
}
|
||||
|
||||
export async function getLanguage(model: Model) {
|
||||
return runPromise((svc) => svc.getLanguage(model))
|
||||
}
|
||||
|
||||
export async function closest(providerID: ProviderID, query: string[]) {
|
||||
return runPromise((svc) => svc.closest(providerID, query))
|
||||
}
|
||||
|
||||
export async function getSmallModel(providerID: ProviderID) {
|
||||
return runPromise((svc) => svc.getSmallModel(providerID))
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
return runPromise((svc) => svc.defaultModel())
|
||||
}
|
||||
|
||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
||||
export function sort<T extends { id: string }>(models: T[]) {
|
||||
return sortBy(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { Proc } from "#pty"
|
||||
import z from "zod"
|
||||
@@ -360,4 +361,34 @@ export namespace Pty {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function get(id: PtyID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
|
||||
export async function write(id: PtyID, data: string) {
|
||||
return runPromise((svc) => svc.write(id, data))
|
||||
}
|
||||
|
||||
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
|
||||
return runPromise((svc) => svc.connect(id, ws, cursor))
|
||||
}
|
||||
|
||||
export async function create(input: CreateInput) {
|
||||
return runPromise((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
export async function update(id: PtyID, input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(id, input))
|
||||
}
|
||||
|
||||
export async function remove(id: PtyID) {
|
||||
return runPromise((svc) => svc.remove(id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Log } from "@/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
@@ -41,12 +39,7 @@ export function ControlPlaneRoutes(): Hono {
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(providerID, info)
|
||||
}),
|
||||
)
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -76,12 +69,7 @@ export function ControlPlaneRoutes(): Hono {
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { Log } from "../../util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -32,7 +30,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
@@ -56,7 +54,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||
await Config.update(config)
|
||||
return c.json(config)
|
||||
},
|
||||
)
|
||||
@@ -84,12 +82,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
using _ = log.time("providers")
|
||||
const providers = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
return mapValues(yield* svc.list(), (item) => item)
|
||||
}),
|
||||
)
|
||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
|
||||
@@ -162,13 +162,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
return c.json(ids)
|
||||
return c.json(await ToolRegistry.ids())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -211,17 +205,11 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: await Agent.get(await Agent.defaultAgent()),
|
||||
})
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
@@ -408,14 +396,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(await MCP.resources())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { File } from "../../file"
|
||||
@@ -73,18 +72,12 @@ export const FileRoutes = lazy(() =>
|
||||
const dirs = c.req.valid("query").dirs
|
||||
const type = c.req.valid("query").type
|
||||
const limit = c.req.valid("query").limit
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) =>
|
||||
svc.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const results = await File.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
})
|
||||
return c.json(results)
|
||||
},
|
||||
)
|
||||
@@ -112,6 +105,11 @@ export const FileRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
/*
|
||||
const query = c.req.valid("query").query
|
||||
const result = await LSP.workspaceSymbol(query)
|
||||
return c.json(result)
|
||||
*/
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
@@ -140,11 +138,7 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.list(path))
|
||||
}),
|
||||
)
|
||||
const content = await File.list(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
@@ -173,11 +167,7 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.read(path))
|
||||
}),
|
||||
)
|
||||
const content = await File.read(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
@@ -199,11 +189,7 @@ export const FileRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.status())
|
||||
}),
|
||||
)
|
||||
const content = await File.status()
|
||||
return c.json(content)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||
return c.json(await Config.getGlobal())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
const next = await Config.updateGlobal(config)
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
@@ -120,17 +119,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch, default_branch }
|
||||
}),
|
||||
),
|
||||
)
|
||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
return c.json({
|
||||
branch,
|
||||
default_branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -157,14 +150,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -229,12 +215,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
const skills = await Skill.all()
|
||||
return c.json(skills)
|
||||
},
|
||||
)
|
||||
@@ -256,8 +237,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
|
||||
return c.json(items)
|
||||
return c.json(await LSP.status())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
|
||||
@@ -3,10 +3,8 @@ import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Config } from "../../config/config"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -28,7 +26,7 @@ export const McpRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
|
||||
return c.json(await MCP.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -58,7 +56,7 @@ export const McpRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
|
||||
const result = await MCP.add(name, config)
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
@@ -86,21 +84,12 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
auth: yield* mcp.startAuth(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.auth)
|
||||
const result = await MCP.startAuth(name)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -131,7 +120,7 @@ export const McpRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
|
||||
const status = await MCP.finishAuth(name, code)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
@@ -155,21 +144,12 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
status: yield* mcp.authenticate(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.status)
|
||||
const status = await MCP.authenticate(name)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
@@ -192,7 +172,7 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
|
||||
await MCP.removeAuth(name)
|
||||
return c.json({ success: true as const })
|
||||
},
|
||||
)
|
||||
@@ -215,7 +195,7 @@ export const McpRoutes = lazy(() =>
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
|
||||
await MCP.connect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -238,7 +218,7 @@ export const McpRoutes = lazy(() =>
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
|
||||
await MCP.disconnect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
})
|
||||
}
|
||||
|
||||
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
|
||||
const adaptor = await getAdaptor(workspace.type)
|
||||
const target = await adaptor.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Log } from "../../util/log"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -41,36 +40,27 @@ export const ProviderRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
const config = await Config.get()
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const allProviders = await ModelsDev.get()
|
||||
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
|
||||
for (const [key, value] of Object.entries(allProviders)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filteredProviders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const connected = await Provider.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return c.json({
|
||||
all: result.all,
|
||||
default: result.default,
|
||||
connected: result.connected,
|
||||
all: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
connected: Object.keys(connected),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Hono, type MiddlewareHandler } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
@@ -29,14 +27,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(await Pty.list())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -59,12 +50,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("json", Pty.CreateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
const info = await Pty.create(c.req.valid("json"))
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
@@ -88,12 +74,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
const info = await Pty.get(c.req.valid("param").ptyID)
|
||||
if (!info) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
}
|
||||
@@ -121,12 +102,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
return c.json(info)
|
||||
},
|
||||
)
|
||||
@@ -150,12 +126,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
await Pty.remove(c.req.valid("param").ptyID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -179,11 +150,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
upgradeWebSocket(async (c) => {
|
||||
type Handler = {
|
||||
onMessage: (message: string | ArrayBuffer) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const id = PtyID.zod.parse(c.req.param("ptyID"))
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
@@ -192,17 +158,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
if (
|
||||
!(await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(id)
|
||||
}),
|
||||
))
|
||||
) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
let handler: Awaited<ReturnType<typeof Pty.connect>>
|
||||
if (!(await Pty.get(id))) throw new Error("Session not found")
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
@@ -228,12 +185,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
handler = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, socket, cursor)
|
||||
}),
|
||||
)
|
||||
handler = await Pty.connect(id, socket, cursor)
|
||||
ready = true
|
||||
for (const msg of pending) handler?.onMessage(msg)
|
||||
pending.length = 0
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { listAdaptors } from "../../control-plane/adaptors"
|
||||
import { Workspace } from "../../control-plane/workspace"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const WorkspaceAdaptor = z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/adaptor",
|
||||
describeRoute({
|
||||
summary: "List workspace adaptors",
|
||||
description: "List all available workspace adaptors for the current project.",
|
||||
operationId: "experimental.workspace.adaptor.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace adaptors",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(WorkspaceAdaptor)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listAdaptors(Instance.project.id))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
|
||||
@@ -94,24 +94,14 @@ export namespace LLM {
|
||||
modelID: input.model.id,
|
||||
providerID: input.model.providerID,
|
||||
})
|
||||
const [language, cfg, provider, info] = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const cfg = yield* Config.Service
|
||||
const provider = yield* Provider.Service
|
||||
return yield* Effect.all(
|
||||
[
|
||||
provider.getLanguage(input.model),
|
||||
cfg.get(),
|
||||
provider.getProvider(input.model.providerID),
|
||||
auth.get(input.model.providerID),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
|
||||
)
|
||||
const [language, cfg, provider, auth] = await Promise.all([
|
||||
Provider.getLanguage(input.model),
|
||||
Config.get(),
|
||||
Provider.getProvider(input.model.providerID),
|
||||
Auth.get(input.model.providerID),
|
||||
])
|
||||
// TODO: move this to a proper hook
|
||||
const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
|
||||
const isOpenaiOauth = provider.id === "openai" && auth?.type === "oauth"
|
||||
|
||||
const system: string[] = []
|
||||
system.push(
|
||||
@@ -210,7 +200,7 @@ export namespace LLM {
|
||||
},
|
||||
)
|
||||
|
||||
const tools = resolveTools(input)
|
||||
const tools = await resolveTools(input)
|
||||
|
||||
// LiteLLM and some Anthropic proxies require the tools parameter to be present
|
||||
// when message history contains tool calls, even if no tools are being used.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { NotFoundError, eq, and, sql } from "../storage/db"
|
||||
import { NotFoundError, eq, and } from "../storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "./index"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Log } from "../util/log"
|
||||
import { DateTime } from "effect"
|
||||
|
||||
const log = Log.create({ service: "session.projector" })
|
||||
|
||||
@@ -133,33 +132,4 @@ export default [
|
||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||
}
|
||||
}),
|
||||
|
||||
// Experimental
|
||||
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
||||
/*
|
||||
const id = SessionEntry.ID.make(data.part.id.replace("prt", "ent"))
|
||||
switch (data.part.type) {
|
||||
case "text":
|
||||
db.insert(SessionEntryTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: data.sessionID,
|
||||
type: "text",
|
||||
data: new SessionEntry.Text({
|
||||
id,
|
||||
text: data.part.text,
|
||||
type: "text",
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(data.part.time?.start ?? Date.now()),
|
||||
completed: data.part.time?.end ? DateTime.makeUnsafe(data.part.time.end) : undefined,
|
||||
},
|
||||
}),
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({ target: SessionEntryTable.id, set: { data: sql`excluded.data` } })
|
||||
.run()
|
||||
}
|
||||
*/
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { SessionEntry } from "../v2/session-entry"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
import type { Permission } from "../permission"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
@@ -11,7 +10,6 @@ import { Timestamps } from "../storage/schema.sql"
|
||||
|
||||
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
|
||||
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
|
||||
type EntryData = Omit<SessionEntry.Entry, "id" | "type">
|
||||
|
||||
export const SessionTable = sqliteTable(
|
||||
"session",
|
||||
@@ -96,27 +94,6 @@ export const TodoTable = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
/*
|
||||
export const SessionEntryTable = sqliteTable(
|
||||
"session_entry",
|
||||
{
|
||||
id: text().$type<SessionEntry.ID>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().notNull(),
|
||||
...Timestamps,
|
||||
data: text({ mode: "json" }).notNull().$type<SessionEntry.Entry>(),
|
||||
},
|
||||
(table) => [
|
||||
index("session_entry_session_idx").on(table.session_id),
|
||||
index("session_entry_session_type_idx").on(table.session_id, table.type),
|
||||
index("session_entry_time_created_idx").on(table.time_created),
|
||||
],
|
||||
)
|
||||
*/
|
||||
|
||||
export const PermissionTable = sqliteTable("permission", {
|
||||
project_id: text()
|
||||
.primaryKey()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -261,4 +262,22 @@ export namespace Skill {
|
||||
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((skill) => skill.get(name))
|
||||
}
|
||||
|
||||
export async function all() {
|
||||
return runPromise((skill) => skill.all())
|
||||
}
|
||||
|
||||
export async function dirs() {
|
||||
return runPromise((skill) => skill.dirs())
|
||||
}
|
||||
|
||||
export async function available(agent?: Agent.Info) {
|
||||
return runPromise((skill) => skill.available(agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
@@ -343,4 +344,18 @@ export namespace ToolRegistry {
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function ids() {
|
||||
return runPromise((svc) => svc.ids())
|
||||
}
|
||||
|
||||
export async function tools(input: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
agent: Agent.Info
|
||||
}): Promise<(Tool.Def & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(input))
|
||||
}
|
||||
}
|
||||
|
||||
115
packages/opencode/src/v2/message.ts
Normal file
115
packages/opencode/src/v2/message.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
|
||||
export namespace Message {
|
||||
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.ascending("message")),
|
||||
prefix: "msg",
|
||||
})),
|
||||
)
|
||||
|
||||
export class Source extends Schema.Class<Source>("Message.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(url: string) {
|
||||
return new FileAttachment({
|
||||
uri: url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Message.User")({
|
||||
id: ID,
|
||||
type: Schema.Literal("user"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
|
||||
const msg = new User({
|
||||
id: ID.create(),
|
||||
type: "user",
|
||||
...input,
|
||||
time: {
|
||||
created: Effect.runSync(DateTime.now),
|
||||
},
|
||||
})
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
|
||||
id: ID,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Message.Request")({
|
||||
id: ID,
|
||||
type: Schema.Literal("start"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Text extends Schema.Class<Text>("Message.Text")({
|
||||
id: ID,
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Complete extends Schema.Class<Complete>("Message.Complete")({
|
||||
id: ID,
|
||||
type: Schema.Literal("complete"),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
cost: Schema.Number,
|
||||
tokens: Schema.Struct({
|
||||
total: Schema.Number,
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const Info = Schema.Union([User, Text])
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
|
||||
export namespace SessionEntry {
|
||||
export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.ascending("entry")),
|
||||
prefix: "ent",
|
||||
})),
|
||||
)
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}
|
||||
|
||||
export class Source extends Schema.Class<Source>("Session.Entry.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Entry.File.Attachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(url: string) {
|
||||
return new FileAttachment({
|
||||
uri: url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Entry.Agent.Attachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Entry.User")({
|
||||
...Base,
|
||||
type: Schema.Literal("user"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
|
||||
const msg = new User({
|
||||
id: ID.create(),
|
||||
type: "user",
|
||||
...input,
|
||||
time: {
|
||||
created: Effect.runSync(DateTime.now),
|
||||
},
|
||||
})
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Session.Entry.Request")({
|
||||
...Base,
|
||||
type: Schema.Literal("start"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Text extends Schema.Class<Text>("Session.Entry.Text")({
|
||||
...Base,
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Reasoning extends Schema.Class<Reasoning>("Session.Entry.Reasoning")({
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
raw: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
|
||||
status: Schema.Literal("running"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
title: Schema.String.pipe(Schema.optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
|
||||
status: Schema.Literal("completed"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
output: Schema.String,
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
|
||||
status: Schema.Literal("error"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class Tool extends Schema.Class<Tool>("Session.Entry.Tool")({
|
||||
...Base,
|
||||
type: Schema.Literal("tool"),
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
ran: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Complete extends Schema.Class<Complete>("Session.Entry.Complete")({
|
||||
...Base,
|
||||
type: Schema.Literal("complete"),
|
||||
cost: Schema.Number,
|
||||
reason: Schema.String,
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Retry extends Schema.Class<Retry>("Session.Entry.Retry")({
|
||||
...Base,
|
||||
type: Schema.Literal("retry"),
|
||||
attempt: Schema.Number,
|
||||
error: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||
...Base,
|
||||
type: Schema.Literal("compaction"),
|
||||
auto: Schema.Boolean,
|
||||
overflow: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction])
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context, Layer, Schema, Effect } from "effect"
|
||||
import { SessionEntry } from "./session-entry"
|
||||
import { Message } from "./message"
|
||||
import { Struct } from "effect"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
@@ -12,8 +12,8 @@ export namespace SessionV2 {
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
|
||||
...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(SessionEntry.ID),
|
||||
...Struct.omit(Message.User.fields, ["time", "type"]),
|
||||
id: Schema.optionalKey(Message.ID),
|
||||
sessionID: SessionV2.ID,
|
||||
}) {}
|
||||
|
||||
@@ -33,7 +33,7 @@ export namespace SessionV2 {
|
||||
export interface Interface {
|
||||
fromID: (id: SessionV2.ID) => Effect.Effect<Info>
|
||||
create: (input: CreateInput) => Effect.Effect<Info>
|
||||
prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
|
||||
prompt: (input: PromptInput) => Effect.Effect<Message.User>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
|
||||
|
||||
@@ -17,10 +17,10 @@ import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export namespace Worktree {
|
||||
const log = Log.create({ service: "worktree" })
|
||||
@@ -267,7 +267,7 @@ export namespace Worktree {
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => undefined,
|
||||
})
|
||||
.then(() => true)
|
||||
|
||||
@@ -1,86 +1,58 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { test, expect } from "bun:test"
|
||||
import { Auth } from "../../src/auth"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node))
|
||||
|
||||
describe("Auth", () => {
|
||||
it.live("set normalizes trailing slashes in keys", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeDefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("set cleans up pre-existing trailing-slash entry", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "old",
|
||||
})
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "new",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
|
||||
expect(keys).toEqual(["https://example.com"])
|
||||
const entry = data["https://example.com"]!
|
||||
expect(entry.type).toBe("wellknown")
|
||||
if (entry.type === "wellknown") expect(entry.token).toBe("new")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("remove deletes both trailing-slash and normalized keys", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
yield* auth.remove("https://example.com/")
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeUndefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("set and remove are no-ops on keys without trailing slashes", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["anthropic"]).toBeDefined()
|
||||
yield* auth.remove("anthropic")
|
||||
const after = yield* auth.all()
|
||||
expect(after["anthropic"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
test("set normalizes trailing slashes in keys", async () => {
|
||||
await Auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
const data = await Auth.all()
|
||||
expect(data["https://example.com"]).toBeDefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("set cleans up pre-existing trailing-slash entry", async () => {
|
||||
// Simulate a pre-fix entry with trailing slash
|
||||
await Auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "old",
|
||||
})
|
||||
// Re-login with normalized key (as the CLI does post-fix)
|
||||
await Auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "new",
|
||||
})
|
||||
const data = await Auth.all()
|
||||
const keys = Object.keys(data).filter((k) => k.includes("example.com"))
|
||||
expect(keys).toEqual(["https://example.com"])
|
||||
const entry = data["https://example.com"]!
|
||||
expect(entry.type).toBe("wellknown")
|
||||
if (entry.type === "wellknown") expect(entry.token).toBe("new")
|
||||
})
|
||||
|
||||
test("remove deletes both trailing-slash and normalized keys", async () => {
|
||||
await Auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
await Auth.remove("https://example.com/")
|
||||
const data = await Auth.all()
|
||||
expect(data["https://example.com"]).toBeUndefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("set and remove are no-ops on keys without trailing slashes", async () => {
|
||||
await Auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
const data = await Auth.all()
|
||||
expect(data["anthropic"]).toBeDefined()
|
||||
await Auth.remove("anthropic")
|
||||
const after = await Auth.all()
|
||||
expect(after["anthropic"]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -13,7 +13,9 @@ const TestEvent = {
|
||||
Pong: BusEvent.define("test.effect.pong", z.object({ message: z.string() })),
|
||||
}
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const node = NodeChildProcessSpawner.layer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
|
||||
const live = Layer.mergeAll(Bus.layer, node)
|
||||
|
||||
|
||||
@@ -5,9 +5,6 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Color } from "../../src/util/color"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
|
||||
test("agent color parsed from project config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -27,7 +24,7 @@ test("agent color parsed from project config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
const cfg = await Config.get()
|
||||
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
||||
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
||||
},
|
||||
|
||||
@@ -33,25 +33,15 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
const it = testEffect(
|
||||
Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
||||
const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const save = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const listDirs = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const ready = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
|
||||
const installDeps = (dir: string, input?: Config.InstallInput) =>
|
||||
Config.Service.use((svc) => svc.installDependencies(dir, input))
|
||||
|
||||
@@ -59,12 +49,12 @@ const installDeps = (dir: string, input?: Config.InstallInput) =>
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
beforeEach(async () => {
|
||||
await clear(true)
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||
await clear(true)
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
async function writeManagedSettings(settings: object, filename = "opencode.json") {
|
||||
@@ -82,7 +72,7 @@ async function check(map: (dir: string) => string) {
|
||||
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
await clear()
|
||||
await Config.invalidate()
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -91,7 +81,7 @@ async function check(map: (dir: string) => string) {
|
||||
await Instance.provide({
|
||||
directory: map(tmp.path),
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
const cfg = await Config.get()
|
||||
expect(cfg.snapshot).toBe(true)
|
||||
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
|
||||
expect(Instance.project.id).not.toBe(ProjectID.global)
|
||||
@@ -100,7 +90,7 @@ async function check(map: (dir: string) => string) {
|
||||
} finally {
|
||||
await Instance.disposeAll()
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear()
|
||||
await Config.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +99,7 @@ test("loads config with defaults when no files exist", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBeDefined()
|
||||
},
|
||||
})
|
||||
@@ -128,7 +118,7 @@ test("loads JSON config file", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
@@ -166,7 +156,7 @@ test("ignores legacy tui keys in opencode config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect((config as Record<string, unknown>).theme).toBeUndefined()
|
||||
expect((config as Record<string, unknown>).tui).toBeUndefined()
|
||||
@@ -191,7 +181,7 @@ test("loads JSONC config file", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
@@ -219,7 +209,7 @@ test("jsonc overrides json in the same directory", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
@@ -242,7 +232,7 @@ test("handles environment variable substitution", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
@@ -274,7 +264,7 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
@@ -368,7 +358,7 @@ test("handles file inclusion substitution", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
@@ -387,7 +377,7 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
},
|
||||
})
|
||||
@@ -406,7 +396,7 @@ test("validates config schema and throws on invalid fields", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(load()).rejects.toThrow()
|
||||
await expect(Config.get()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -420,7 +410,7 @@ test("throws error for invalid JSON", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load()).rejects.toThrow()
|
||||
await expect(Config.get()).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -443,7 +433,7 @@ test("handles agent configuration", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
@@ -474,7 +464,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const agent = config.agent?.["test_agent"]
|
||||
|
||||
expect(agent?.variant).toBe("xhigh")
|
||||
@@ -504,7 +494,7 @@ test("handles command configuration", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.command?.["test_command"]).toEqual({
|
||||
template: "test template",
|
||||
description: "test command",
|
||||
@@ -529,7 +519,7 @@ test("migrates autoshare to share field", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.share).toBe("auto")
|
||||
expect(config.autoshare).toBe(true)
|
||||
},
|
||||
@@ -556,7 +546,7 @@ test("migrates mode field to agent field", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test_mode"]).toEqual({
|
||||
model: "test/model",
|
||||
temperature: 0.5,
|
||||
@@ -588,7 +578,7 @@ Test agent prompt`,
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "test",
|
||||
@@ -632,7 +622,7 @@ Nested agent prompt`,
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.agent?.["helper"]).toMatchObject({
|
||||
name: "helper",
|
||||
@@ -681,7 +671,7 @@ Nested command template`,
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.command?.["hello"]).toEqual({
|
||||
description: "Test command",
|
||||
@@ -726,7 +716,7 @@ Nested command template`,
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
|
||||
expect(config.command?.["hello"]).toEqual({
|
||||
description: "Test command",
|
||||
@@ -747,7 +737,7 @@ test("updates config and writes to file", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await save(newConfig as any)
|
||||
await Config.update(newConfig as any)
|
||||
|
||||
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
@@ -760,7 +750,7 @@ test("gets config directories", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const dirs = await listDirs()
|
||||
const dirs = await Config.directories()
|
||||
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
})
|
||||
@@ -790,7 +780,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await load()
|
||||
await Config.get()
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
@@ -824,8 +814,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await load()
|
||||
await ready()
|
||||
await Config.get()
|
||||
await Config.waitForDependencies()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1006,7 +996,7 @@ test("resolves scoped npm plugins in config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const pluginEntries = config.plugin ?? []
|
||||
expect(pluginEntries).toContain("@scope/plugin")
|
||||
},
|
||||
@@ -1044,7 +1034,7 @@ test("merges plugin arrays from global and local configs", async () => {
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
// Should contain both global and local plugins
|
||||
@@ -1080,7 +1070,7 @@ Helper subagent prompt`,
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["helper"]).toMatchObject({
|
||||
name: "helper",
|
||||
model: "test/model",
|
||||
@@ -1119,7 +1109,7 @@ test("merges instructions arrays from global and local configs", async () => {
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const instructions = config.instructions ?? []
|
||||
|
||||
expect(instructions).toContain("global-instructions.md")
|
||||
@@ -1158,7 +1148,7 @@ test("deduplicates duplicate instructions from global and local configs", async
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const instructions = config.instructions ?? []
|
||||
|
||||
expect(instructions).toContain("global-only.md")
|
||||
@@ -1203,7 +1193,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
// Should contain all unique plugins
|
||||
@@ -1252,7 +1242,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
const cfg = await Config.get()
|
||||
const plugins = cfg.plugin ?? []
|
||||
const origins = cfg.plugin_origins ?? []
|
||||
const names = plugins.map((item) => Config.pluginSpecifier(item))
|
||||
@@ -1293,7 +1283,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "allow",
|
||||
read: "allow",
|
||||
@@ -1324,7 +1314,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "deny",
|
||||
webfetch: "deny",
|
||||
@@ -1354,7 +1344,7 @@ test("migrates legacy write tool to edit permission", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "allow",
|
||||
})
|
||||
@@ -1386,7 +1376,7 @@ test("managed settings override user settings", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("managed/model")
|
||||
expect(config.share).toBe("disabled")
|
||||
expect(config.username).toBe("testuser")
|
||||
@@ -1414,7 +1404,7 @@ test("managed settings override project settings", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.autoupdate).toBe(false)
|
||||
expect(config.disabled_providers).toEqual(["openai"])
|
||||
},
|
||||
@@ -1434,7 +1424,7 @@ test("missing managed settings file is not an error", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("user/model")
|
||||
},
|
||||
})
|
||||
@@ -1461,7 +1451,7 @@ test("migrates legacy edit tool to edit permission", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "deny",
|
||||
})
|
||||
@@ -1490,7 +1480,7 @@ test("migrates legacy patch tool to edit permission", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "allow",
|
||||
})
|
||||
@@ -1519,7 +1509,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
edit: "deny",
|
||||
})
|
||||
@@ -1551,7 +1541,7 @@ test("migrates mixed legacy tools config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
bash: "allow",
|
||||
edit: "allow",
|
||||
@@ -1586,7 +1576,7 @@ test("merges legacy tools with existing permission config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.agent?.["test"]?.permission).toEqual({
|
||||
glob: "allow",
|
||||
bash: "allow",
|
||||
@@ -1621,7 +1611,7 @@ test("permission config preserves key order", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(Object.keys(config.permission!)).toEqual([
|
||||
"*",
|
||||
"edit",
|
||||
@@ -1681,7 +1671,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
// jira should be enabled (overridden by project config)
|
||||
expect(config.mcp?.jira).toEqual({
|
||||
type: "remote",
|
||||
@@ -1737,7 +1727,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.mcp?.myserver).toEqual({
|
||||
type: "remote",
|
||||
url: "https://myserver.example.com/mcp",
|
||||
@@ -1788,7 +1778,7 @@ test("local .opencode config can override MCP from project config", async () =>
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.mcp?.docs?.enabled).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -2039,7 +2029,7 @@ describe("deduplicatePluginOrigins", () => {
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
||||
@@ -2071,7 +2061,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
// Project config should NOT be loaded - model should be default, not "project/model"
|
||||
expect(config.model).not.toBe("project/model")
|
||||
expect(config.username).not.toBe("project-user")
|
||||
@@ -2102,7 +2092,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const directories = await listDirs()
|
||||
const directories = await Config.directories()
|
||||
// Project .opencode should NOT be in directories list
|
||||
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
|
||||
expect(hasProjectOpencode).toBe(false)
|
||||
@@ -2127,7 +2117,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Should still get default config (from global or defaults)
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config).toBeDefined()
|
||||
expect(config.username).toBeDefined()
|
||||
},
|
||||
@@ -2170,7 +2160,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
fn: async () => {
|
||||
// The relative instruction should be skipped without error
|
||||
// We're mainly verifying this doesn't throw and the config loads
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config).toBeDefined()
|
||||
// The instruction should have been skipped (warning logged)
|
||||
// We can't easily test the warning was logged, but we verify
|
||||
@@ -2228,7 +2218,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
// Should load from OPENCODE_CONFIG_DIR, not project
|
||||
expect(config.model).toBe("configdir/model")
|
||||
},
|
||||
@@ -2263,7 +2253,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("test_api_key_12345")
|
||||
},
|
||||
})
|
||||
@@ -2297,7 +2287,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
expect(config.username).toBe("secret_key_from_file")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,15 +7,12 @@ import { Config } from "../../src/config/config"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
|
||||
beforeEach(async () => {
|
||||
await clear(true)
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -26,7 +23,7 @@ afterEach(async () => {
|
||||
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||
await clear(true)
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
test("keeps server and tui plugin merge semantics aligned", async () => {
|
||||
@@ -82,7 +79,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await load()
|
||||
const server = await Config.get()
|
||||
const tui = await TuiConfig.get()
|
||||
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import type { WorkspaceInfo } from "../../src/control-plane/types"
|
||||
|
||||
function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
|
||||
return {
|
||||
id: "workspace-test" as WorkspaceInfo["id"],
|
||||
type,
|
||||
name: "workspace-test",
|
||||
branch: null,
|
||||
directory: null,
|
||||
extra: null,
|
||||
projectID,
|
||||
}
|
||||
}
|
||||
|
||||
function adaptor(dir: string) {
|
||||
return {
|
||||
name: dir,
|
||||
description: dir,
|
||||
configure(input: WorkspaceInfo) {
|
||||
return input
|
||||
},
|
||||
async create() {},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory: dir,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("control-plane/adaptors", () => {
|
||||
test("isolates custom adaptors by project", async () => {
|
||||
const type = `demo-${Math.random().toString(36).slice(2)}`
|
||||
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
registerAdaptor(one, type, adaptor("/one"))
|
||||
registerAdaptor(two, type, adaptor("/two"))
|
||||
|
||||
expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/one",
|
||||
})
|
||||
expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/two",
|
||||
})
|
||||
})
|
||||
|
||||
test("latest install wins within a project", async () => {
|
||||
const type = `demo-${Math.random().toString(36).slice(2)}`
|
||||
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
|
||||
registerAdaptor(id, type, adaptor("/one"))
|
||||
|
||||
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/one",
|
||||
})
|
||||
|
||||
registerAdaptor(id, type, adaptor("/two"))
|
||||
|
||||
expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
|
||||
type: "local",
|
||||
directory: "/two",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,10 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
|
||||
const status = () => run(File.Service.use((svc) => svc.status()))
|
||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
|
||||
@@ -33,7 +27,7 @@ describe("file fsmonitor", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await status()
|
||||
await File.status()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -58,7 +52,7 @@ describe("file fsmonitor", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await read("tracked.txt")
|
||||
await File.read("tracked.txt")
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const init = () => run(File.Service.use((svc) => svc.init()))
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
|
||||
const status = () => run(File.Service.use((svc) => svc.status()))
|
||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
||||
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
|
||||
const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
|
||||
run(File.Service.use((svc) => svc.search(input)))
|
||||
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("read() - text content", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Filesystem.readText()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
@@ -31,7 +21,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("Hello World")
|
||||
},
|
||||
@@ -45,7 +35,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Non-existent file should return empty content
|
||||
const result = await read("nonexistent.txt")
|
||||
const result = await File.read("nonexistent.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
@@ -60,7 +50,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.content).toBe("content with spaces")
|
||||
},
|
||||
})
|
||||
@@ -74,7 +64,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("empty.txt")
|
||||
const result = await File.read("empty.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
@@ -89,14 +79,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("multiline.txt")
|
||||
const result = await File.read("multiline.txt")
|
||||
expect(result.content).toBe("line1\nline2\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("read() - binary content", () => {
|
||||
describe("File.read() - binary content", () => {
|
||||
test("reads binary file via Filesystem.readArrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "image.png")
|
||||
@@ -106,7 +96,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("image.png")
|
||||
const result = await File.read("image.png")
|
||||
expect(result.type).toBe("text") // Images return as text with base64 encoding
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/png")
|
||||
@@ -123,7 +113,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("binary.so")
|
||||
const result = await File.read("binary.so")
|
||||
expect(result.type).toBe("binary")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
@@ -131,7 +121,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("read() - Filesystem.mimeType()", () => {
|
||||
describe("File.read() - Filesystem.mimeType()", () => {
|
||||
test("detects MIME type via Filesystem.mimeType()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.json")
|
||||
@@ -142,7 +132,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
fn: async () => {
|
||||
expect(Filesystem.mimeType(filepath)).toContain("application/json")
|
||||
|
||||
const result = await read("test.json")
|
||||
const result = await File.read("test.json")
|
||||
expect(result.type).toBe("text")
|
||||
},
|
||||
})
|
||||
@@ -171,7 +161,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("list() - Filesystem.exists() and readText()", () => {
|
||||
describe("File.list() - Filesystem.exists() and readText()", () => {
|
||||
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
@@ -181,7 +171,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
|
||||
|
||||
// This is used internally in list()
|
||||
// This is used internally in File.list()
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(true)
|
||||
|
||||
const content = await Filesystem.readText(gitignorePath)
|
||||
@@ -214,8 +204,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(false)
|
||||
|
||||
// list() should still work
|
||||
const nodes = await list()
|
||||
// File.list() should still work
|
||||
const nodes = await File.list()
|
||||
expect(Array.isArray(nodes)).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -254,8 +244,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
// Filesystem.readText() on non-existent file throws
|
||||
await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
|
||||
|
||||
// But read() handles this gracefully
|
||||
const result = await read("does-not-exist.txt")
|
||||
// But File.read() handles this gracefully
|
||||
const result = await File.read("does-not-exist.txt")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
})
|
||||
@@ -282,8 +272,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// read() handles missing images gracefully
|
||||
const result = await read("broken.png")
|
||||
// File.read() handles missing images gracefully
|
||||
const result = await File.read("broken.png")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("")
|
||||
},
|
||||
@@ -300,7 +290,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.ts")
|
||||
const result = await File.read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
@@ -315,7 +305,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.mts")
|
||||
const result = await File.read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
@@ -330,7 +320,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.sh")
|
||||
const result = await File.read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
|
||||
},
|
||||
@@ -345,7 +335,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("Dockerfile")
|
||||
const result = await File.read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("FROM alpine:3.20")
|
||||
},
|
||||
@@ -360,7 +350,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.txt")
|
||||
const result = await File.read("test.txt")
|
||||
expect(result.encoding).toBeUndefined()
|
||||
expect(result.type).toBe("text")
|
||||
},
|
||||
@@ -375,7 +365,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("test.jpg")
|
||||
const result = await File.read("test.jpg")
|
||||
expect(result.encoding).toBe("base64")
|
||||
expect(result.mimeType).toBe("image/jpeg")
|
||||
},
|
||||
@@ -390,7 +380,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -401,13 +391,13 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("status()", () => {
|
||||
describe("File.status()", () => {
|
||||
test("detects modified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
@@ -419,7 +409,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "file.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
@@ -436,7 +426,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "new.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("added")
|
||||
@@ -457,7 +447,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
|
||||
const entries = result.filter((f) => f.path === "gone.txt")
|
||||
expect(entries.some((e) => e.status === "deleted")).toBe(true)
|
||||
@@ -480,7 +470,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
|
||||
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
|
||||
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
|
||||
@@ -494,7 +484,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
@@ -506,7 +496,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
@@ -529,7 +519,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await status()
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "data.bin")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
@@ -540,7 +530,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("list()", () => {
|
||||
describe("File.list()", () => {
|
||||
test("returns files and directories with correct shape", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
@@ -550,7 +540,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
for (const node of nodes) {
|
||||
expect(node).toHaveProperty("name")
|
||||
@@ -574,7 +564,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
const nodes = await File.list()
|
||||
const dirs = nodes.filter((n) => n.type === "directory")
|
||||
const files = nodes.filter((n) => n.type === "file")
|
||||
// Dirs come first
|
||||
@@ -599,7 +589,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
const nodes = await File.list()
|
||||
const names = nodes.map((n) => n.name)
|
||||
expect(names).not.toContain(".git")
|
||||
expect(names).not.toContain(".DS_Store")
|
||||
@@ -618,7 +608,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
const nodes = await File.list()
|
||||
const logNode = nodes.find((n) => n.name === "app.log")
|
||||
const tsNode = nodes.find((n) => n.name === "main.ts")
|
||||
const buildNode = nodes.find((n) => n.name === "build")
|
||||
@@ -638,7 +628,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list("sub")
|
||||
const nodes = await File.list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
|
||||
// Paths should be relative to project root (normalize for Windows)
|
||||
@@ -653,7 +643,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(list("../outside")).rejects.toThrow("Access denied")
|
||||
await expect(File.list("../outside")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -665,7 +655,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await list()
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
// Without git, ignored should be false for all
|
||||
for (const node of nodes) {
|
||||
@@ -676,7 +666,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("search()", () => {
|
||||
describe("File.search()", () => {
|
||||
async function setupSearchableRepo() {
|
||||
const tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
|
||||
@@ -695,9 +685,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "", type: "file" })
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
@@ -709,7 +699,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await search({ query: "main", type: "file" })
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -721,9 +711,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "", type: "directory" })
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Find first hidden dir index
|
||||
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
@@ -741,9 +731,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "main", type: "file" })
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -755,9 +745,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "", type: "file" })
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
// Files don't end with /
|
||||
for (const f of result) {
|
||||
expect(f.endsWith("/")).toBe(false)
|
||||
@@ -772,9 +762,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "", type: "directory" })
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
// Directories end with /
|
||||
for (const d of result) {
|
||||
expect(d.endsWith("/")).toBe(true)
|
||||
@@ -789,9 +779,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: "", type: "file", limit: 2 })
|
||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
},
|
||||
})
|
||||
@@ -803,9 +793,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
await File.init()
|
||||
|
||||
const result = await search({ query: ".hidden", type: "directory" })
|
||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toContain(".hidden")
|
||||
},
|
||||
@@ -818,19 +808,19 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
expect(await search({ query: "fresh", type: "file" })).toEqual([])
|
||||
await File.init()
|
||||
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
|
||||
|
||||
const result = await search({ query: "fresh", type: "file" })
|
||||
const result = await File.search({ query: "fresh", type: "file" })
|
||||
expect(result).toContain("fresh.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("read() - diff/patch", () => {
|
||||
describe("File.read() - diff/patch", () => {
|
||||
test("returns diff and patch for modified tracked file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
@@ -842,7 +832,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("file.txt")
|
||||
const result = await File.read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("modified content")
|
||||
expect(result.diff).toBeDefined()
|
||||
@@ -866,7 +856,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("staged.txt")
|
||||
const result = await File.read("staged.txt")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.patch).toBeDefined()
|
||||
},
|
||||
@@ -883,7 +873,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("clean.txt")
|
||||
const result = await File.read("clean.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("unchanged")
|
||||
expect(result.diff).toBeUndefined()
|
||||
@@ -903,10 +893,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
const results = await search({ query: "a.ts", type: "file" })
|
||||
await File.init()
|
||||
const results = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results).toContain("a.ts")
|
||||
const results2 = await search({ query: "b.ts", type: "file" })
|
||||
const results2 = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results2).not.toContain("b.ts")
|
||||
},
|
||||
})
|
||||
@@ -914,10 +904,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
const results = await search({ query: "b.ts", type: "file" })
|
||||
await File.init()
|
||||
const results = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results).toContain("b.ts")
|
||||
const results2 = await search({ query: "a.ts", type: "file" })
|
||||
const results2 = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results2).not.toContain("a.ts")
|
||||
},
|
||||
})
|
||||
@@ -930,8 +920,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
const results = await search({ query: "before", type: "file" })
|
||||
await File.init()
|
||||
const results = await File.search({ query: "before", type: "file" })
|
||||
expect(results).toContain("before.ts")
|
||||
},
|
||||
})
|
||||
@@ -944,10 +934,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await init()
|
||||
const results = await search({ query: "after", type: "file" })
|
||||
await File.init()
|
||||
const results = await File.search({ query: "after", type: "file" })
|
||||
expect(results).toContain("after.ts")
|
||||
const stale = await search({ query: "before", type: "file" })
|
||||
const stale = await File.search({ query: "before", type: "file" })
|
||||
expect(stale).not.toContain("before.ts")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
|
||||
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
|
||||
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Filesystem.contains", () => {
|
||||
test("allows paths within project", () => {
|
||||
@@ -38,10 +32,10 @@ describe("Filesystem.contains", () => {
|
||||
})
|
||||
|
||||
/*
|
||||
* Integration tests for read() and list() path traversal protection.
|
||||
* Integration tests for File.read() and File.list() path traversal protection.
|
||||
*
|
||||
* These tests verify the HTTP API code path is protected. The HTTP endpoints
|
||||
* in server.ts (GET /file/content, GET /file) call read()/list()
|
||||
* in server.ts (GET /file/content, GET /file) call File.read()/File.list()
|
||||
* directly - they do NOT go through ReadTool or the agent permission layer.
|
||||
*
|
||||
* This is a SEPARATE code path from ReadTool, which has its own checks.
|
||||
@@ -57,7 +51,7 @@ describe("File.read path traversal protection", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -68,7 +62,7 @@ describe("File.read path traversal protection", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
||||
await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
||||
"Access denied: path escapes project directory",
|
||||
)
|
||||
},
|
||||
@@ -85,7 +79,7 @@ describe("File.read path traversal protection", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await read("valid.txt")
|
||||
const result = await File.read("valid.txt")
|
||||
expect(result.content).toBe("valid content")
|
||||
},
|
||||
})
|
||||
@@ -99,7 +93,7 @@ describe("File.list path traversal protection", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -114,7 +108,7 @@ describe("File.list path traversal protection", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await list("subdir")
|
||||
const result = await File.list("subdir")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import * as Lsp from "../../src/lsp/index"
|
||||
import { LSPServer } from "../../src/lsp/server"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
it.live("does not spawn builtin LSP for files outside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
test("does not spawn builtin LSP for files outside instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
|
||||
await Lsp.LSP.hover({
|
||||
file: path.join(tmp.path, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
test("would spawn builtin LSP for files inside instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Lsp.LSP.hover({
|
||||
file: path.join(tmp.path, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import * as Lsp from "../../src/lsp/index"
|
||||
import { LSPServer } from "../../src/lsp/server"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
function withInstance(fn: (dir: string) => Promise<void>) {
|
||||
return async () => {
|
||||
await using tmp = await tmpdir()
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => fn(tmp.path),
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("LSP service lifecycle", () => {
|
||||
let spawnSpy: ReturnType<typeof spyOn>
|
||||
@@ -20,112 +30,97 @@ describe("LSP service lifecycle", () => {
|
||||
spawnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init())))
|
||||
|
||||
it.live("status() returns empty array initially", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.status()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"init() completes without error",
|
||||
withInstance(async () => {
|
||||
await Lsp.LSP.init()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("diagnostics() returns empty object initially", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.diagnostics()
|
||||
expect(typeof result).toBe("object")
|
||||
expect(Object.keys(result).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"status() returns empty array initially",
|
||||
withInstance(async () => {
|
||||
const result = await Lsp.LSP.status()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"diagnostics() returns empty object initially",
|
||||
withInstance(async () => {
|
||||
const result = await Lsp.LSP.diagnostics()
|
||||
expect(typeof result).toBe("object")
|
||||
expect(Object.keys(result).length).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns false for files outside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts"))
|
||||
expect(typeof result).toBe("boolean")
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"hasClients() returns true for .ts files in instance",
|
||||
withInstance(async (dir) => {
|
||||
const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("workspaceSymbol() returns empty array with no clients", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.workspaceSymbol("test")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"hasClients() returns false for files outside instance",
|
||||
withInstance(async (dir) => {
|
||||
const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts"))
|
||||
// hasClients checks servers but doesn't check containsPath — getClients does
|
||||
// So hasClients may return true even for outside files (it checks extension + root)
|
||||
// The guard is in getClients, not hasClients
|
||||
expect(typeof result).toBe("boolean")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("definition() returns empty array for unknown file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.definition({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"workspaceSymbol() returns empty array with no clients",
|
||||
withInstance(async () => {
|
||||
const result = await Lsp.LSP.workspaceSymbol("test")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("references() returns empty array for unknown file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.references({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"definition() returns empty array for unknown file",
|
||||
withInstance(async (dir) => {
|
||||
const result = await Lsp.LSP.definition({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("multiple init() calls are idempotent", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
}),
|
||||
),
|
||||
),
|
||||
test(
|
||||
"references() returns empty array for unknown file",
|
||||
withInstance(async (dir) => {
|
||||
const result = await Lsp.LSP.references({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test(
|
||||
"multiple init() calls are idempotent",
|
||||
withInstance(async () => {
|
||||
await Lsp.LSP.init()
|
||||
await Lsp.LSP.init()
|
||||
await Lsp.LSP.init()
|
||||
// Should not throw or create duplicate state
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("LSP.Diagnostic", () => {
|
||||
test("pretty() formats error diagnostic", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
const result = Lsp.LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } },
|
||||
message: "Type 'string' is not assignable to type 'number'",
|
||||
severity: 1,
|
||||
@@ -134,7 +129,7 @@ describe("LSP.Diagnostic", () => {
|
||||
})
|
||||
|
||||
test("pretty() formats warning diagnostic", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
const result = Lsp.LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
|
||||
message: "Unused variable",
|
||||
severity: 2,
|
||||
@@ -143,7 +138,7 @@ describe("LSP.Diagnostic", () => {
|
||||
})
|
||||
|
||||
test("pretty() defaults to ERROR when no severity", () => {
|
||||
const result = LSP.Diagnostic.pretty({
|
||||
const result = Lsp.LSP.Diagnostic.pretty({
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
||||
message: "Something wrong",
|
||||
} as any)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// Track what options were passed to each transport constructor
|
||||
const transportCalls: Array<{
|
||||
@@ -46,10 +44,8 @@ beforeEach(() => {
|
||||
|
||||
// Import MCP after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { AppRuntime } = await import("../../src/effect/app-runtime")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { tmpdir } = await import("../fixture/fixture")
|
||||
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
||||
|
||||
test("headers are passed to transports when oauth is enabled (default)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -77,21 +73,14 @@ test("headers are passed to transports when oauth is enabled (default)", async (
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Trigger MCP initialization - it will fail to connect but we can check the transport options
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
yield* mcp
|
||||
.add("test-server", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
)
|
||||
await MCP.add("test-server", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
// Both transports should have been created with headers
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
@@ -117,21 +106,14 @@ test("headers are passed to transports when oauth is explicitly disabled", async
|
||||
fn: async () => {
|
||||
transportCalls.length = 0
|
||||
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
yield* mcp
|
||||
.add("test-server-no-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
oauth: false,
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
)
|
||||
await MCP.add("test-server-no-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
oauth: false,
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
@@ -155,17 +137,10 @@ test("no requestInit when headers are not provided", async () => {
|
||||
fn: async () => {
|
||||
transportCalls.length = 0
|
||||
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
yield* mcp
|
||||
.add("test-server-no-headers", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
)
|
||||
await MCP.add("test-server-no-headers", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
}).catch(() => {})
|
||||
|
||||
expect(transportCalls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// --- Mock infrastructure ---
|
||||
|
||||
@@ -172,10 +170,7 @@ const { tmpdir } = await import("../fixture/fixture")
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
function withInstance(
|
||||
config: Record<string, unknown>,
|
||||
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
|
||||
) {
|
||||
function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
|
||||
return async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -192,7 +187,7 @@ function withInstance(
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
|
||||
await fn()
|
||||
// dispose instance to clean up state between tests
|
||||
await Instance.dispose()
|
||||
},
|
||||
@@ -206,30 +201,28 @@ function withInstance(
|
||||
|
||||
test(
|
||||
"tools() reuses cached tool definitions after connect",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "my-server"
|
||||
const serverState = getOrCreateClientState("my-server")
|
||||
serverState.tools = [
|
||||
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
|
||||
]
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "my-server"
|
||||
const serverState = getOrCreateClientState("my-server")
|
||||
serverState.tools = [
|
||||
{ name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
|
||||
]
|
||||
|
||||
// First: add the server successfully
|
||||
const addResult = yield* mcp.add("my-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
|
||||
// First: add the server successfully
|
||||
const addResult = await MCP.add("my-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
|
||||
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
|
||||
const toolsA = yield* mcp.tools()
|
||||
const toolsB = yield* mcp.tools()
|
||||
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
|
||||
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
}),
|
||||
),
|
||||
const toolsA = await MCP.tools()
|
||||
const toolsB = await MCP.tools()
|
||||
expect(Object.keys(toolsA).length).toBeGreaterThan(0)
|
||||
expect(Object.keys(toolsB).length).toBeGreaterThan(0)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -238,32 +231,30 @@ test(
|
||||
|
||||
test(
|
||||
"tool change notifications refresh cached tool definitions",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "status-server"
|
||||
const serverState = getOrCreateClientState("status-server")
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "status-server"
|
||||
const serverState = getOrCreateClientState("status-server")
|
||||
|
||||
yield* mcp.add("status-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("status-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const before = yield* mcp.tools()
|
||||
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
const before = await MCP.tools()
|
||||
expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
|
||||
expect(serverState.listToolsCalls).toBe(1)
|
||||
|
||||
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
|
||||
serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
|
||||
|
||||
const handler = Array.from(serverState.notificationHandlers.values())[0]
|
||||
expect(handler).toBeDefined()
|
||||
yield* Effect.promise(() => handler?.())
|
||||
const handler = Array.from(serverState.notificationHandlers.values())[0]
|
||||
expect(handler).toBeDefined()
|
||||
await handler?.()
|
||||
|
||||
const after = yield* mcp.tools()
|
||||
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
|
||||
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
|
||||
expect(serverState.listToolsCalls).toBe(2)
|
||||
}),
|
||||
),
|
||||
const after = await MCP.tools()
|
||||
expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
|
||||
expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
|
||||
expect(serverState.listToolsCalls).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -279,29 +270,28 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "disc-server"
|
||||
getOrCreateClientState("disc-server")
|
||||
async () => {
|
||||
lastCreatedClientName = "disc-server"
|
||||
getOrCreateClientState("disc-server")
|
||||
|
||||
yield* mcp.add("disc-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("disc-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const statusBefore = yield* mcp.status()
|
||||
expect(statusBefore["disc-server"]?.status).toBe("connected")
|
||||
const statusBefore = await MCP.status()
|
||||
expect(statusBefore["disc-server"]?.status).toBe("connected")
|
||||
|
||||
yield* mcp.disconnect("disc-server")
|
||||
await MCP.disconnect("disc-server")
|
||||
|
||||
const statusAfter = yield* mcp.status()
|
||||
expect(statusAfter["disc-server"]?.status).toBe("disabled")
|
||||
const statusAfter = await MCP.status()
|
||||
expect(statusAfter["disc-server"]?.status).toBe("disabled")
|
||||
|
||||
// Tools should be empty after disconnect
|
||||
const tools = yield* mcp.tools()
|
||||
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
|
||||
expect(serverTools.length).toBe(0)
|
||||
}),
|
||||
// Tools should be empty after disconnect
|
||||
const tools = await MCP.tools()
|
||||
const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
|
||||
expect(serverTools.length).toBe(0)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -314,29 +304,26 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "reconn-server"
|
||||
const serverState = getOrCreateClientState("reconn-server")
|
||||
serverState.tools = [
|
||||
{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
|
||||
]
|
||||
async () => {
|
||||
lastCreatedClientName = "reconn-server"
|
||||
const serverState = getOrCreateClientState("reconn-server")
|
||||
serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
|
||||
|
||||
yield* mcp.add("reconn-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("reconn-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
yield* mcp.disconnect("reconn-server")
|
||||
expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
|
||||
await MCP.disconnect("reconn-server")
|
||||
expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
|
||||
|
||||
// Reconnect
|
||||
yield* mcp.connect("reconn-server")
|
||||
expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
|
||||
// Reconnect
|
||||
await MCP.connect("reconn-server")
|
||||
expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
|
||||
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
|
||||
}),
|
||||
const tools = await MCP.tools()
|
||||
expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -348,32 +335,30 @@ test(
|
||||
"add() closes the old client when replacing a server",
|
||||
// Don't put the server in config — add it dynamically so we control
|
||||
// exactly which client instance is "first" vs "second".
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "replace-server"
|
||||
const firstState = getOrCreateClientState("replace-server")
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "replace-server"
|
||||
const firstState = getOrCreateClientState("replace-server")
|
||||
|
||||
yield* mcp.add("replace-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("replace-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(firstState.closed).toBe(false)
|
||||
expect(firstState.closed).toBe(false)
|
||||
|
||||
// Create new state for second client
|
||||
clientStates.delete("replace-server")
|
||||
const secondState = getOrCreateClientState("replace-server")
|
||||
// Create new state for second client
|
||||
clientStates.delete("replace-server")
|
||||
const secondState = getOrCreateClientState("replace-server")
|
||||
|
||||
// Re-add should close the first client
|
||||
yield* mcp.add("replace-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
// Re-add should close the first client
|
||||
await MCP.add("replace-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
expect(firstState.closed).toBe(true)
|
||||
expect(secondState.closed).toBe(false)
|
||||
}),
|
||||
),
|
||||
expect(firstState.closed).toBe(true)
|
||||
expect(secondState.closed).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -393,38 +378,37 @@ test(
|
||||
command: ["echo", "bad"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
// Set up good server
|
||||
const goodState = getOrCreateClientState("good-server")
|
||||
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
|
||||
async () => {
|
||||
// Set up good server
|
||||
const goodState = getOrCreateClientState("good-server")
|
||||
goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
|
||||
|
||||
// Set up bad server - will fail on listTools during create()
|
||||
const badState = getOrCreateClientState("bad-server")
|
||||
badState.listToolsShouldFail = true
|
||||
// Set up bad server - will fail on listTools during create()
|
||||
const badState = getOrCreateClientState("bad-server")
|
||||
badState.listToolsShouldFail = true
|
||||
|
||||
// Add good server first
|
||||
lastCreatedClientName = "good-server"
|
||||
yield* mcp.add("good-server", {
|
||||
type: "local",
|
||||
command: ["echo", "good"],
|
||||
})
|
||||
// Add good server first
|
||||
lastCreatedClientName = "good-server"
|
||||
await MCP.add("good-server", {
|
||||
type: "local",
|
||||
command: ["echo", "good"],
|
||||
})
|
||||
|
||||
// Add bad server - should fail but not affect good server
|
||||
lastCreatedClientName = "bad-server"
|
||||
yield* mcp.add("bad-server", {
|
||||
type: "local",
|
||||
command: ["echo", "bad"],
|
||||
})
|
||||
// Add bad server - should fail but not affect good server
|
||||
lastCreatedClientName = "bad-server"
|
||||
await MCP.add("bad-server", {
|
||||
type: "local",
|
||||
command: ["echo", "bad"],
|
||||
})
|
||||
|
||||
const status = yield* mcp.status()
|
||||
expect(status["good-server"]?.status).toBe("connected")
|
||||
expect(status["bad-server"]?.status).toBe("failed")
|
||||
const status = await MCP.status()
|
||||
expect(status["good-server"]?.status).toBe("connected")
|
||||
expect(status["bad-server"]?.status).toBe("failed")
|
||||
|
||||
// Good server's tools should still be available
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
|
||||
}),
|
||||
// Good server's tools should still be available
|
||||
const tools = await MCP.tools()
|
||||
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -442,22 +426,21 @@ test(
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const countBefore = clientCreateCount
|
||||
async () => {
|
||||
const countBefore = clientCreateCount
|
||||
|
||||
yield* mcp.add("disabled-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
enabled: false,
|
||||
} as any)
|
||||
await MCP.add("disabled-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
enabled: false,
|
||||
} as any)
|
||||
|
||||
// No client should have been created
|
||||
expect(clientCreateCount).toBe(countBefore)
|
||||
// No client should have been created
|
||||
expect(clientCreateCount).toBe(countBefore)
|
||||
|
||||
const status = yield* mcp.status()
|
||||
expect(status["disabled-server"]?.status).toBe("disabled")
|
||||
}),
|
||||
const status = await MCP.status()
|
||||
expect(status["disabled-server"]?.status).toBe("disabled")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -474,23 +457,22 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "prompt-server"
|
||||
const serverState = getOrCreateClientState("prompt-server")
|
||||
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
|
||||
async () => {
|
||||
lastCreatedClientName = "prompt-server"
|
||||
const serverState = getOrCreateClientState("prompt-server")
|
||||
serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
|
||||
|
||||
yield* mcp.add("prompt-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("prompt-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const prompts = yield* mcp.prompts()
|
||||
expect(Object.keys(prompts).length).toBe(1)
|
||||
const key = Object.keys(prompts)[0]
|
||||
expect(key).toContain("prompt-server")
|
||||
expect(key).toContain("my-prompt")
|
||||
}),
|
||||
const prompts = await MCP.prompts()
|
||||
expect(Object.keys(prompts).length).toBe(1)
|
||||
const key = Object.keys(prompts)[0]
|
||||
expect(key).toContain("prompt-server")
|
||||
expect(key).toContain("my-prompt")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -503,23 +485,22 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "resource-server"
|
||||
const serverState = getOrCreateClientState("resource-server")
|
||||
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
|
||||
async () => {
|
||||
lastCreatedClientName = "resource-server"
|
||||
const serverState = getOrCreateClientState("resource-server")
|
||||
serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
|
||||
|
||||
yield* mcp.add("resource-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("resource-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const resources = yield* mcp.resources()
|
||||
expect(Object.keys(resources).length).toBe(1)
|
||||
const key = Object.keys(resources)[0]
|
||||
expect(key).toContain("resource-server")
|
||||
expect(key).toContain("my-resource")
|
||||
}),
|
||||
const resources = await MCP.resources()
|
||||
expect(Object.keys(resources).length).toBe(1)
|
||||
const key = Object.keys(resources)[0]
|
||||
expect(key).toContain("resource-server")
|
||||
expect(key).toContain("my-resource")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -532,22 +513,21 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "prompt-disc-server"
|
||||
const serverState = getOrCreateClientState("prompt-disc-server")
|
||||
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
|
||||
async () => {
|
||||
lastCreatedClientName = "prompt-disc-server"
|
||||
const serverState = getOrCreateClientState("prompt-disc-server")
|
||||
serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
|
||||
|
||||
yield* mcp.add("prompt-disc-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("prompt-disc-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
yield* mcp.disconnect("prompt-disc-server")
|
||||
await MCP.disconnect("prompt-disc-server")
|
||||
|
||||
const prompts = yield* mcp.prompts()
|
||||
expect(Object.keys(prompts).length).toBe(0)
|
||||
}),
|
||||
const prompts = await MCP.prompts()
|
||||
expect(Object.keys(prompts).length).toBe(0)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -557,14 +537,12 @@ test(
|
||||
|
||||
test(
|
||||
"connect() on nonexistent server does not throw",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
// Should not throw
|
||||
yield* mcp.connect("nonexistent")
|
||||
const status = yield* mcp.status()
|
||||
expect(status["nonexistent"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
withInstance({}, async () => {
|
||||
// Should not throw
|
||||
await MCP.connect("nonexistent")
|
||||
const status = await MCP.status()
|
||||
expect(status["nonexistent"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -573,12 +551,10 @@ test(
|
||||
|
||||
test(
|
||||
"disconnect() on nonexistent server does not throw",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* mcp.disconnect("nonexistent")
|
||||
// Should complete without error
|
||||
}),
|
||||
),
|
||||
withInstance({}, async () => {
|
||||
await MCP.disconnect("nonexistent")
|
||||
// Should complete without error
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -587,12 +563,10 @@ test(
|
||||
|
||||
test(
|
||||
"tools() returns empty when no MCP servers are configured",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
withInstance({}, async () => {
|
||||
const tools = await MCP.tools()
|
||||
expect(Object.keys(tools).length).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -608,28 +582,27 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "fail-connect"
|
||||
getOrCreateClientState("fail-connect")
|
||||
connectShouldFail = true
|
||||
connectError = "Connection refused"
|
||||
async () => {
|
||||
lastCreatedClientName = "fail-connect"
|
||||
getOrCreateClientState("fail-connect")
|
||||
connectShouldFail = true
|
||||
connectError = "Connection refused"
|
||||
|
||||
yield* mcp.add("fail-connect", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("fail-connect", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const status = yield* mcp.status()
|
||||
expect(status["fail-connect"]?.status).toBe("failed")
|
||||
if (status["fail-connect"]?.status === "failed") {
|
||||
expect(status["fail-connect"].error).toContain("Connection refused")
|
||||
}
|
||||
const status = await MCP.status()
|
||||
expect(status["fail-connect"]?.status).toBe("failed")
|
||||
if (status["fail-connect"]?.status === "failed") {
|
||||
expect(status["fail-connect"].error).toContain("Connection refused")
|
||||
}
|
||||
|
||||
// No tools should be available
|
||||
const tools = yield* mcp.tools()
|
||||
expect(Object.keys(tools).length).toBe(0)
|
||||
}),
|
||||
// No tools should be available
|
||||
const tools = await MCP.tools()
|
||||
expect(Object.keys(tools).length).toBe(0)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -675,29 +648,28 @@ test(
|
||||
command: ["echo", "test"],
|
||||
},
|
||||
},
|
||||
(mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "my.special-server"
|
||||
const serverState = getOrCreateClientState("my.special-server")
|
||||
serverState.tools = [
|
||||
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
|
||||
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
|
||||
]
|
||||
async () => {
|
||||
lastCreatedClientName = "my.special-server"
|
||||
const serverState = getOrCreateClientState("my.special-server")
|
||||
serverState.tools = [
|
||||
{ name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
|
||||
{ name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
|
||||
]
|
||||
|
||||
yield* mcp.add("my.special-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
await MCP.add("my.special-server", {
|
||||
type: "local",
|
||||
command: ["echo", "test"],
|
||||
})
|
||||
|
||||
const tools = yield* mcp.tools()
|
||||
const keys = Object.keys(tools)
|
||||
const tools = await MCP.tools()
|
||||
const keys = Object.keys(tools)
|
||||
|
||||
// Server name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
|
||||
// Tool name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
|
||||
expect(keys.length).toBe(2)
|
||||
}),
|
||||
// Server name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
|
||||
// Tool name dots should be replaced with underscores
|
||||
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
|
||||
expect(keys.length).toBe(2)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -707,25 +679,23 @@ test(
|
||||
|
||||
test(
|
||||
"local stdio transport is closed when connect times out (no process leak)",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "hanging-server"
|
||||
getOrCreateClientState("hanging-server")
|
||||
connectShouldHang = true
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "hanging-server"
|
||||
getOrCreateClientState("hanging-server")
|
||||
connectShouldHang = true
|
||||
|
||||
const addResult = yield* mcp.add("hanging-server", {
|
||||
type: "local",
|
||||
command: ["node", "fake.js"],
|
||||
timeout: 100,
|
||||
})
|
||||
const addResult = await MCP.add("hanging-server", {
|
||||
type: "local",
|
||||
command: ["node", "fake.js"],
|
||||
timeout: 100,
|
||||
})
|
||||
|
||||
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
expect(serverStatus.error).toContain("timed out")
|
||||
// Transport must be closed to avoid orphaned child process
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
),
|
||||
const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
expect(serverStatus.error).toContain("timed out")
|
||||
// Transport must be closed to avoid orphaned child process
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -734,25 +704,23 @@ test(
|
||||
|
||||
test(
|
||||
"remote transport is closed when connect times out",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "hanging-remote"
|
||||
getOrCreateClientState("hanging-remote")
|
||||
connectShouldHang = true
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "hanging-remote"
|
||||
getOrCreateClientState("hanging-remote")
|
||||
connectShouldHang = true
|
||||
|
||||
const addResult = yield* mcp.add("hanging-remote", {
|
||||
type: "remote",
|
||||
url: "http://localhost:9999/mcp",
|
||||
timeout: 100,
|
||||
oauth: false,
|
||||
})
|
||||
const addResult = await MCP.add("hanging-remote", {
|
||||
type: "remote",
|
||||
url: "http://localhost:9999/mcp",
|
||||
timeout: 100,
|
||||
oauth: false,
|
||||
})
|
||||
|
||||
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
// Transport must be closed to avoid leaked HTTP connections
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
),
|
||||
const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
// Transport must be closed to avoid leaked HTTP connections
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
// ========================================================================
|
||||
@@ -761,24 +729,22 @@ test(
|
||||
|
||||
test(
|
||||
"failed remote transport is closed before trying next transport",
|
||||
withInstance({}, (mcp) =>
|
||||
Effect.gen(function* () {
|
||||
lastCreatedClientName = "fail-remote"
|
||||
getOrCreateClientState("fail-remote")
|
||||
connectShouldFail = true
|
||||
connectError = "Connection refused"
|
||||
withInstance({}, async () => {
|
||||
lastCreatedClientName = "fail-remote"
|
||||
getOrCreateClientState("fail-remote")
|
||||
connectShouldFail = true
|
||||
connectError = "Connection refused"
|
||||
|
||||
const addResult = yield* mcp.add("fail-remote", {
|
||||
type: "remote",
|
||||
url: "http://localhost:9999/mcp",
|
||||
timeout: 5000,
|
||||
oauth: false,
|
||||
})
|
||||
const addResult = await MCP.add("fail-remote", {
|
||||
type: "remote",
|
||||
url: "http://localhost:9999/mcp",
|
||||
timeout: 5000,
|
||||
oauth: false,
|
||||
})
|
||||
|
||||
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
// Both StreamableHTTP and SSE transports should be closed
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
|
||||
}),
|
||||
),
|
||||
const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
|
||||
expect(serverStatus.status).toBe("failed")
|
||||
// Both StreamableHTTP and SSE transports should be closed
|
||||
expect(transportCloseCount).toBeGreaterThanOrEqual(2)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// Mock UnauthorizedError to match the SDK's class
|
||||
class MockUnauthorizedError extends Error {
|
||||
@@ -124,14 +122,10 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await Effect.runPromise(
|
||||
MCP.Service.use((mcp) =>
|
||||
mcp.add("test-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
}),
|
||||
).pipe(Effect.provide(MCP.defaultLayer)),
|
||||
)
|
||||
const result = await MCP.add("test-oauth", {
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
|
||||
const serverStatus = result.status as Record<string, { status: string; error?: string }>
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { EventEmitter } from "events"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
// Track open() calls and control failure behavior
|
||||
let openShouldFail = false
|
||||
@@ -102,12 +100,10 @@ beforeEach(() => {
|
||||
|
||||
// Import modules after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { AppRuntime } = await import("../../src/effect/app-runtime")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { tmpdir } = await import("../fixture/fixture")
|
||||
const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
|
||||
|
||||
test("BrowserOpenFailed event is published when open() throws", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -140,12 +136,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
|
||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||
// Attach a handler immediately so callback shutdown rejections
|
||||
// don't show up as unhandled between tests.
|
||||
const authPromise = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
return yield* mcp.authenticate("test-oauth-server")
|
||||
}),
|
||||
).catch(() => undefined)
|
||||
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
|
||||
|
||||
// Config.get() can be slow in tests, so give it plenty of time.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
@@ -194,12 +185,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
|
||||
})
|
||||
|
||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||
const authPromise = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
return yield* mcp.authenticate("test-oauth-server-2")
|
||||
}),
|
||||
).catch(() => undefined)
|
||||
const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
|
||||
|
||||
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
@@ -244,12 +230,7 @@ test("open() is called with the authorization URL", async () => {
|
||||
openCalledWith = undefined
|
||||
|
||||
// Run authenticate with a timeout to avoid waiting forever for the callback
|
||||
const authPromise = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* service
|
||||
return yield* mcp.authenticate("test-oauth-server-3")
|
||||
}),
|
||||
).catch(() => undefined)
|
||||
const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
|
||||
|
||||
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
|
||||
@@ -3,9 +3,6 @@ import { Permission } from "../src/permission"
|
||||
import { Config } from "../src/config/config"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import { AppRuntime } from "../src/effect/app-runtime"
|
||||
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
@@ -161,7 +158,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
@@ -186,7 +183,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
@@ -211,7 +208,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
@@ -238,7 +235,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Verify task permissions
|
||||
@@ -276,7 +273,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
@@ -307,7 +304,7 @@ describe("permission.task with real config files", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const config = await Config.get()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
|
||||
@@ -125,9 +125,6 @@ test("remaps fallback oauth model urls to the enterprise host", async () => {
|
||||
project: {} as never,
|
||||
directory: "",
|
||||
worktree: "",
|
||||
experimental_workspace: {
|
||||
register() {},
|
||||
},
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: {} as never,
|
||||
})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { afterAll, afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
return
|
||||
}
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
})
|
||||
|
||||
describe("plugin.workspace", () => {
|
||||
test("plugin can install a workspace adaptor", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const type = `plug-${Math.random().toString(36).slice(2)}`
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const mark = path.join(dir, "created.json")
|
||||
const space = path.join(dir, "space")
|
||||
await Bun.write(
|
||||
file,
|
||||
[
|
||||
"export default async ({ experimental_workspace }) => {",
|
||||
` experimental_workspace.register(${JSON.stringify(type)}, {`,
|
||||
' name: "plug",',
|
||||
' description: "plugin workspace adaptor",',
|
||||
" configure(input) {",
|
||||
` return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`,
|
||||
" },",
|
||||
" async create(input) {",
|
||||
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
|
||||
" },",
|
||||
" async remove() {},",
|
||||
" target(input) {",
|
||||
' return { type: "local", directory: input.directory }',
|
||||
" },",
|
||||
" })",
|
||||
" return {}",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(file).href],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return { mark, space, type }
|
||||
},
|
||||
})
|
||||
|
||||
const info = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Plugin.init()
|
||||
return Workspace.create({
|
||||
type: tmp.extra.type,
|
||||
branch: null,
|
||||
extra: { key: "value" },
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(info.type).toBe(tmp.extra.type)
|
||||
expect(info.name).toBe("plug")
|
||||
expect(info.branch).toBe("plug/main")
|
||||
expect(info.directory).toBe(tmp.extra.space)
|
||||
expect(info.extra).toEqual({ key: "value" })
|
||||
expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({
|
||||
type: tmp.extra.type,
|
||||
name: "plug",
|
||||
branch: "plug/main",
|
||||
directory: tmp.extra.space,
|
||||
extra: { key: "value" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -21,14 +20,8 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcher.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* watcher.init()
|
||||
yield* vcs.init()
|
||||
}),
|
||||
)
|
||||
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||
Vcs.init()
|
||||
await Bun.sleep(500)
|
||||
await body()
|
||||
},
|
||||
@@ -39,12 +32,7 @@ function withVcsOnly(directory: string, body: () => Promise<void>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.init()
|
||||
}),
|
||||
)
|
||||
Vcs.init()
|
||||
await body()
|
||||
},
|
||||
})
|
||||
@@ -92,12 +80,7 @@ describeVcs("Vcs", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
const branch = await Vcs.branch()
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
@@ -107,12 +90,7 @@ describeVcs("Vcs", () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
const branch = await Vcs.branch()
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -145,12 +123,7 @@ describeVcs("Vcs", () => {
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
|
||||
await pending
|
||||
const current = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
const current = await Vcs.branch()
|
||||
expect(current).toBe(branch)
|
||||
})
|
||||
})
|
||||
@@ -166,12 +139,7 @@ describe("Vcs diff", () => {
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.defaultBranch()
|
||||
}),
|
||||
)
|
||||
const branch = await Vcs.defaultBranch()
|
||||
expect(branch).toBe("main")
|
||||
})
|
||||
})
|
||||
@@ -182,12 +150,7 @@ describe("Vcs diff", () => {
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.defaultBranch()
|
||||
}),
|
||||
)
|
||||
const branch = await Vcs.defaultBranch()
|
||||
expect(branch).toBe("trunk")
|
||||
})
|
||||
})
|
||||
@@ -200,12 +163,7 @@ describe("Vcs diff", () => {
|
||||
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(dir, async () => {
|
||||
const [branch, base] = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
|
||||
}),
|
||||
)
|
||||
const [branch, base] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
expect(branch).toBe("feature/test")
|
||||
expect(base).toBe("main")
|
||||
})
|
||||
@@ -219,12 +177,7 @@ describe("Vcs diff", () => {
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
const diff = await Vcs.diff("git")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -241,12 +194,7 @@ describe("Vcs diff", () => {
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
const diff = await Vcs.diff("git")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -267,12 +215,7 @@ describe("Vcs diff", () => {
|
||||
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("branch")
|
||||
}),
|
||||
)
|
||||
const diff = await Vcs.diff("branch")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -9,17 +9,6 @@ import { Provider } from "../../src/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
async function list() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.list()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -46,7 +35,7 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
@@ -71,7 +60,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
@@ -127,7 +116,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
@@ -172,7 +161,7 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
|
||||
Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
},
|
||||
@@ -203,7 +192,7 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
|
||||
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
@@ -239,7 +228,7 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
|
||||
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
},
|
||||
@@ -279,7 +268,7 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
// The model should exist with the us. prefix
|
||||
expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
@@ -316,7 +305,7 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
@@ -352,7 +341,7 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
@@ -388,7 +377,7 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
|
||||
// Non-prefixed model should still be registered
|
||||
expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-gitlab-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
|
||||
// },
|
||||
@@ -62,7 +62,7 @@
|
||||
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
|
||||
// },
|
||||
@@ -100,7 +100,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// },
|
||||
// })
|
||||
@@ -135,7 +135,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
|
||||
// },
|
||||
@@ -167,7 +167,7 @@
|
||||
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
|
||||
// },
|
||||
@@ -198,7 +198,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "env-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// },
|
||||
// })
|
||||
@@ -221,7 +221,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
|
||||
// "context-1m-2025-08-07",
|
||||
@@ -257,7 +257,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
|
||||
@@ -282,7 +282,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// const models = Object.keys(providers[ProviderID.gitlab].models)
|
||||
// expect(models.length).toBeGreaterThan(0)
|
||||
@@ -306,7 +306,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// const gitlab = providers[ProviderID.gitlab]
|
||||
// expect(gitlab).toBeDefined()
|
||||
// gitlab.models["duo-workflow-sonnet-4-6"] = {
|
||||
@@ -332,10 +332,10 @@
|
||||
// release_date: "",
|
||||
// variants: {},
|
||||
// }
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
|
||||
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
|
||||
// expect(model).toBeDefined()
|
||||
// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
|
||||
// const language = await getLanguage(model)
|
||||
// const language = await Provider.getLanguage(model)
|
||||
// expect(language).toBeDefined()
|
||||
// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
// },
|
||||
@@ -354,11 +354,11 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// expect(model).toBeDefined()
|
||||
// const language = await getLanguage(model)
|
||||
// const language = await Provider.getLanguage(model)
|
||||
// expect(language).toBeDefined()
|
||||
// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
// },
|
||||
@@ -377,10 +377,10 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// const gitlab = providers[ProviderID.gitlab]
|
||||
// expect(gitlab.options?.featureFlags).toBeDefined()
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// const model = await Provider.getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// expect(model).toBeDefined()
|
||||
// expect(model.options).toBeDefined()
|
||||
// },
|
||||
@@ -401,7 +401,7 @@
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const providers = await Provider.list()
|
||||
// const models = Object.keys(providers[ProviderID.gitlab].models)
|
||||
// expect(models).toContain("duo-chat-haiku-4-5")
|
||||
// expect(models).toContain("duo-chat-sonnet-4-5")
|
||||
|
||||
@@ -11,47 +11,8 @@ import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
async function run<A, E>(fn: (provider: Provider.Interface) => Effect.Effect<A, E, never>) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* fn(provider)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function list() {
|
||||
return run((provider) => provider.list())
|
||||
}
|
||||
|
||||
async function getProvider(providerID: ProviderID) {
|
||||
return run((provider) => provider.getProvider(providerID))
|
||||
}
|
||||
|
||||
async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||
return run((provider) => provider.getModel(providerID, modelID))
|
||||
}
|
||||
|
||||
async function getLanguage(model: Provider.Model) {
|
||||
return run((provider) => provider.getLanguage(model))
|
||||
}
|
||||
|
||||
async function closest(providerID: ProviderID, query: string[]) {
|
||||
return run((provider) => provider.closest(providerID, query))
|
||||
}
|
||||
|
||||
async function getSmallModel(providerID: ProviderID) {
|
||||
return run((provider) => provider.getSmallModel(providerID))
|
||||
}
|
||||
|
||||
async function defaultModel() {
|
||||
return run((provider) => provider.defaultModel())
|
||||
}
|
||||
|
||||
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||
function paid(providers: Awaited<ReturnType<typeof Provider.list>>) {
|
||||
const item = providers[ProviderID.make("opencode")]
|
||||
expect(item).toBeDefined()
|
||||
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||
@@ -74,7 +35,7 @@ test("provider loaded from env variable", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
// Provider should retain its connection source even if custom loaders
|
||||
// merge additional options.
|
||||
@@ -105,7 +66,7 @@ test("provider loaded from config with apiKey option", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
},
|
||||
})
|
||||
@@ -129,7 +90,7 @@ test("disabled_providers excludes provider", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -154,7 +115,7 @@ test("enabled_providers restricts to only listed providers", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||
},
|
||||
@@ -183,7 +144,7 @@ test("model whitelist filters models for provider", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
@@ -214,7 +175,7 @@ test("model blacklist excludes specific models", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||
expect(models).not.toContain("claude-sonnet-4-20250514")
|
||||
@@ -249,7 +210,7 @@ test("custom model alias via config", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
|
||||
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
|
||||
@@ -292,7 +253,7 @@ test("custom provider with npm package", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
|
||||
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
|
||||
@@ -325,7 +286,7 @@ test("env variable takes precedence, config merges options", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "env-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
// Config options should be merged
|
||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
||||
@@ -351,11 +312,11 @@ test("getModel returns model for valid provider/model", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||
const language = await getLanguage(model)
|
||||
const language = await Provider.getLanguage(model)
|
||||
expect(language).toBeDefined()
|
||||
},
|
||||
})
|
||||
@@ -378,7 +339,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
|
||||
expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -397,7 +358,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
|
||||
expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -431,7 +392,7 @@ test("defaultModel returns first available model when no config set", async () =
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await defaultModel()
|
||||
const model = await Provider.defaultModel()
|
||||
expect(model.providerID).toBeDefined()
|
||||
expect(model.modelID).toBeDefined()
|
||||
},
|
||||
@@ -456,7 +417,7 @@ test("defaultModel respects config model setting", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await defaultModel()
|
||||
const model = await Provider.defaultModel()
|
||||
expect(String(model.providerID)).toBe("anthropic")
|
||||
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
|
||||
},
|
||||
@@ -495,7 +456,7 @@ test("provider with baseURL from config", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
|
||||
},
|
||||
@@ -533,7 +494,7 @@ test("model cost defaults to zero when not specified", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("test-provider")].models["test-model"]
|
||||
expect(model.cost.input).toBe(0)
|
||||
expect(model.cost.output).toBe(0)
|
||||
@@ -571,7 +532,7 @@ test("model options are merged from existing model", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
},
|
||||
@@ -600,7 +561,7 @@ test("provider removed when all models filtered out", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -623,7 +584,7 @@ test("closest finds model by partial match", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await closest(ProviderID.anthropic, ["sonnet-4"])
|
||||
const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"])
|
||||
expect(result).toBeDefined()
|
||||
expect(String(result?.providerID)).toBe("anthropic")
|
||||
expect(String(result?.modelID)).toContain("sonnet-4")
|
||||
@@ -645,7 +606,7 @@ test("closest returns undefined for nonexistent provider", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await closest(ProviderID.make("nonexistent"), ["model"])
|
||||
const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"])
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -678,10 +639,10 @@ test("getModel uses realIdByKey for aliased models", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
|
||||
|
||||
const model = await getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
|
||||
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model.id)).toBe("my-sonnet")
|
||||
expect(model.name).toBe("My Sonnet Alias")
|
||||
@@ -721,7 +682,7 @@ test("provider api field sets model api.url", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
// api field is stored on model.api.url, used by getSDK to set baseURL
|
||||
expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
|
||||
},
|
||||
@@ -761,7 +722,7 @@ test("explicit baseURL overrides api field", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
|
||||
},
|
||||
})
|
||||
@@ -793,7 +754,7 @@ test("model inherits properties from existing database model", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.name).toBe("Custom Name for Sonnet")
|
||||
expect(model.capabilities.toolcall).toBe(true)
|
||||
@@ -821,7 +782,7 @@ test("disabled_providers prevents loading even with env var", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -846,7 +807,7 @@ test("enabled_providers with empty array allows no providers", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(Object.keys(providers).length).toBe(0)
|
||||
},
|
||||
})
|
||||
@@ -875,7 +836,7 @@ test("whitelist and blacklist can be combined", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||
expect(models).toContain("claude-sonnet-4-20250514")
|
||||
@@ -914,7 +875,7 @@ test("model modalities default correctly", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("test-provider")].models["test-model"]
|
||||
expect(model.capabilities.input.text).toBe(true)
|
||||
expect(model.capabilities.output.text).toBe(true)
|
||||
@@ -957,7 +918,7 @@ test("model with custom cost values", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("test-provider")].models["test-model"]
|
||||
expect(model.cost.input).toBe(5)
|
||||
expect(model.cost.output).toBe(15)
|
||||
@@ -984,7 +945,7 @@ test("getSmallModel returns appropriate small model", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getSmallModel(ProviderID.anthropic)
|
||||
const model = await Provider.getSmallModel(ProviderID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.id).toContain("haiku")
|
||||
},
|
||||
@@ -1009,7 +970,7 @@ test("getSmallModel respects config small_model override", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model = await getSmallModel(ProviderID.anthropic)
|
||||
const model = await Provider.getSmallModel(ProviderID.anthropic)
|
||||
expect(model).toBeDefined()
|
||||
expect(String(model?.providerID)).toBe("anthropic")
|
||||
expect(String(model?.id)).toBe("claude-sonnet-4-20250514")
|
||||
@@ -1058,7 +1019,7 @@ test("multiple providers can be configured simultaneously", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
expect(providers[ProviderID.openai]).toBeDefined()
|
||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
|
||||
@@ -1099,7 +1060,7 @@ test("provider with custom npm package", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("local-llm")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
|
||||
@@ -1136,7 +1097,7 @@ test("model alias name defaults to alias key when id differs", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
|
||||
},
|
||||
})
|
||||
@@ -1176,7 +1137,7 @@ test("provider with multiple env var options only includes apiKey when single en
|
||||
Env.set("MULTI_ENV_KEY_1", "test-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("multi-env")]).toBeDefined()
|
||||
// When multiple env options exist, key should NOT be auto-set
|
||||
expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
|
||||
@@ -1218,7 +1179,7 @@ test("provider with single env var includes apiKey automatically", async () => {
|
||||
Env.set("SINGLE_ENV_KEY", "my-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("single-env")]).toBeDefined()
|
||||
// Single env option should auto-set key
|
||||
expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
|
||||
@@ -1255,7 +1216,7 @@ test("model cost overrides existing cost values", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.cost.input).toBe(999)
|
||||
expect(model.cost.output).toBe(888)
|
||||
@@ -1302,7 +1263,7 @@ test("completely new provider not in database can be configured", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
|
||||
const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
|
||||
@@ -1336,7 +1297,7 @@ test("disabled_providers and enabled_providers interaction", async () => {
|
||||
Env.set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
// anthropic: in enabled, not in disabled = allowed
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
// openai: in enabled, but also in disabled = NOT allowed
|
||||
@@ -1376,7 +1337,7 @@ test("model with tool_call false", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
|
||||
},
|
||||
})
|
||||
@@ -1411,7 +1372,7 @@ test("model defaults tool_call to true when not specified", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
|
||||
},
|
||||
})
|
||||
@@ -1450,7 +1411,7 @@ test("model headers are preserved", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("headers-provider")].models["model"]
|
||||
expect(model.headers).toEqual({
|
||||
"X-Custom-Header": "custom-value",
|
||||
@@ -1493,7 +1454,7 @@ test("provider env fallback - second env var used if first missing", async () =>
|
||||
Env.set("FALLBACK_KEY", "fallback-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
// Provider should load because fallback env var is set
|
||||
expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
|
||||
},
|
||||
@@ -1517,8 +1478,8 @@ test("getModel returns consistent results", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||
expect(model1.providerID).toEqual(model2.providerID)
|
||||
expect(model1.id).toEqual(model2.id)
|
||||
expect(model1).toEqual(model2)
|
||||
@@ -1555,7 +1516,7 @@ test("provider name defaults to id when not in database", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
|
||||
},
|
||||
})
|
||||
@@ -1579,7 +1540,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
|
||||
await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
|
||||
expect(true).toBe(false) // Should not reach here
|
||||
} catch (e: any) {
|
||||
expect(e.data.suggestions).toBeDefined()
|
||||
@@ -1607,7 +1568,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
|
||||
},
|
||||
fn: async () => {
|
||||
try {
|
||||
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
|
||||
await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
|
||||
expect(true).toBe(false) // Should not reach here
|
||||
} catch (e: any) {
|
||||
expect(e.data.suggestions).toBeDefined()
|
||||
@@ -1631,7 +1592,7 @@ test("getProvider returns undefined for nonexistent provider", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const provider = await getProvider(ProviderID.make("nonexistent"))
|
||||
const provider = await Provider.getProvider(ProviderID.make("nonexistent"))
|
||||
expect(provider).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -1654,7 +1615,7 @@ test("getProvider returns provider info", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const provider = await getProvider(ProviderID.anthropic)
|
||||
const provider = await Provider.getProvider(ProviderID.anthropic)
|
||||
expect(provider).toBeDefined()
|
||||
expect(String(provider?.id)).toBe("anthropic")
|
||||
},
|
||||
@@ -1678,7 +1639,7 @@ test("closest returns undefined when no partial match found", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
|
||||
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"])
|
||||
expect(result).toBeUndefined()
|
||||
},
|
||||
})
|
||||
@@ -1702,7 +1663,7 @@ test("closest checks multiple query terms in order", async () => {
|
||||
},
|
||||
fn: async () => {
|
||||
// First term won't match, second will
|
||||
const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"])
|
||||
const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"])
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.modelID).toContain("haiku")
|
||||
},
|
||||
@@ -1738,7 +1699,7 @@ test("model limit defaults to zero when not specified", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("no-limit")].models["model"]
|
||||
expect(model.limit.context).toBe(0)
|
||||
expect(model.limit.output).toBe(0)
|
||||
@@ -1773,7 +1734,7 @@ test("provider options are deeply merged", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
// Custom options should be merged
|
||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
|
||||
expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
|
||||
@@ -1811,7 +1772,7 @@ test("custom model inherits npm package from models.dev provider config", async
|
||||
Env.set("OPENAI_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.openai].models["my-custom-model"]
|
||||
expect(model).toBeDefined()
|
||||
expect(model.api.npm).toBe("@ai-sdk/openai")
|
||||
@@ -1846,7 +1807,7 @@ test("custom model inherits api.url from models.dev provider", async () => {
|
||||
Env.set("OPENROUTER_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.openrouter]).toBeDefined()
|
||||
|
||||
// New model not in database should inherit api.url from provider
|
||||
@@ -1947,7 +1908,7 @@ test("model variants are generated for reasoning models", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
// Claude sonnet 4 has reasoning capability
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.capabilities.reasoning).toBe(true)
|
||||
@@ -1985,7 +1946,7 @@ test("model variants can be disabled via config", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
@@ -2028,7 +1989,7 @@ test("model variants can be customized via config", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
|
||||
@@ -2067,7 +2028,7 @@ test("disabled key is stripped from variant config", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["max"]).toBeDefined()
|
||||
expect(model.variants!["max"].disabled).toBeUndefined()
|
||||
@@ -2105,7 +2066,7 @@ test("all variants can be disabled via config", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(Object.keys(model.variants!).length).toBe(0)
|
||||
@@ -2143,7 +2104,7 @@ test("variant config merges with generated variants", async () => {
|
||||
Env.set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.variants!["high"]).toBeDefined()
|
||||
// Should have both the generated thinking config and the custom option
|
||||
@@ -2181,7 +2142,7 @@ test("variants filtered in second pass for database models", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-api-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.openai].models["gpt-5"]
|
||||
expect(model.variants).toBeDefined()
|
||||
expect(model.variants!["high"]).toBeUndefined()
|
||||
@@ -2227,7 +2188,7 @@ test("custom model with variants enabled and disabled", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
|
||||
expect(model.variants).toBeDefined()
|
||||
// Enabled variants should exist
|
||||
@@ -2285,7 +2246,7 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
|
||||
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
|
||||
},
|
||||
@@ -2330,7 +2291,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
|
||||
Env.set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
|
||||
|
||||
expect(model).toBeDefined()
|
||||
@@ -2358,7 +2319,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
|
||||
Env.set("CLOUDFLARE_API_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||
},
|
||||
})
|
||||
@@ -2390,7 +2351,7 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
|
||||
Env.set("CLOUDFLARE_API_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
|
||||
invoked_by: "test",
|
||||
@@ -2438,7 +2399,7 @@ test("plugin config providers persist after instance dispose", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Plugin.init()
|
||||
return list()
|
||||
return Provider.list()
|
||||
},
|
||||
})
|
||||
expect(first[ProviderID.make("demo")]).toBeDefined()
|
||||
@@ -2448,7 +2409,7 @@ test("plugin config providers persist after instance dispose", async () => {
|
||||
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => list(),
|
||||
fn: async () => Provider.list(),
|
||||
})
|
||||
expect(second[ProviderID.make("demo")]).toBeDefined()
|
||||
expect(second[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined()
|
||||
@@ -2484,7 +2445,7 @@ test("plugin config enabled and disabled providers are honored", async () => {
|
||||
Env.set("OPENAI_API_KEY", "test-openai-key")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const providers = await Provider.list()
|
||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||
},
|
||||
@@ -2505,7 +2466,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
|
||||
|
||||
const none = await Instance.provide({
|
||||
directory: base.path,
|
||||
fn: async () => paid(await list()),
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
|
||||
await using keyed = await tmpdir({
|
||||
@@ -2528,7 +2489,7 @@ test("opencode loader keeps paid models when config apiKey is present", async ()
|
||||
|
||||
const keyedCount = await Instance.provide({
|
||||
directory: keyed.path,
|
||||
fn: async () => paid(await list()),
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
|
||||
expect(none).toBe(0)
|
||||
@@ -2549,7 +2510,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
|
||||
|
||||
const none = await Instance.provide({
|
||||
directory: base.path,
|
||||
fn: async () => paid(await list()),
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
|
||||
await using keyed = await tmpdir({
|
||||
@@ -2583,7 +2544,7 @@ test("opencode loader keeps paid models when auth exists", async () => {
|
||||
|
||||
const keyedCount = await Instance.provide({
|
||||
directory: keyed.path,
|
||||
fn: async () => paid(await list()),
|
||||
fn: async () => paid(await Provider.list()),
|
||||
})
|
||||
|
||||
expect(none).toBe(0)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -12,48 +10,48 @@ describe("pty", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* pty.create({ command: "cat", title: "a" })
|
||||
const b = yield* pty.create({ command: "cat", title: "b" })
|
||||
try {
|
||||
const outA: string[] = []
|
||||
const outB: string[] = []
|
||||
fn: async () => {
|
||||
const a = await Pty.create({ command: "cat", title: "a" })
|
||||
const b = await Pty.create({ command: "cat", title: "b" })
|
||||
try {
|
||||
const outA: string[] = []
|
||||
const outB: string[] = []
|
||||
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: { events: { connection: "a" } },
|
||||
send: (data: unknown) => {
|
||||
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op (simulate abrupt drop)
|
||||
},
|
||||
}
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: { events: { connection: "a" } },
|
||||
send: (data: unknown) => {
|
||||
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op (simulate abrupt drop)
|
||||
},
|
||||
}
|
||||
|
||||
yield* pty.connect(a.id, ws as any)
|
||||
// Connect "a" first with ws.
|
||||
Pty.connect(a.id, ws as any)
|
||||
|
||||
ws.data = { events: { connection: "b" } }
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
}
|
||||
yield* pty.connect(b.id, ws as any)
|
||||
// Now "reuse" the same ws object for another connection.
|
||||
ws.data = { events: { connection: "b" } }
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
}
|
||||
Pty.connect(b.id, ws as any)
|
||||
|
||||
outA.length = 0
|
||||
outB.length = 0
|
||||
// Clear connect metadata writes.
|
||||
outA.length = 0
|
||||
outB.length = 0
|
||||
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
yield* Effect.promise(() => sleep(100))
|
||||
// Output from a must never show up in b.
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
yield* pty.remove(a.id)
|
||||
yield* pty.remove(b.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
await Pty.remove(a.id)
|
||||
await Pty.remove(b.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,43 +60,42 @@ describe("pty", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* pty.create({ command: "cat", title: "a" })
|
||||
try {
|
||||
const outA: string[] = []
|
||||
const outB: string[] = []
|
||||
fn: async () => {
|
||||
const a = await Pty.create({ command: "cat", title: "a" })
|
||||
try {
|
||||
const outA: string[] = []
|
||||
const outB: string[] = []
|
||||
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: { events: { connection: "a" } },
|
||||
send: (data: unknown) => {
|
||||
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op (simulate abrupt drop)
|
||||
},
|
||||
}
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: { events: { connection: "a" } },
|
||||
send: (data: unknown) => {
|
||||
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op (simulate abrupt drop)
|
||||
},
|
||||
}
|
||||
|
||||
yield* pty.connect(a.id, ws as any)
|
||||
outA.length = 0
|
||||
// Connect "a" first.
|
||||
Pty.connect(a.id, ws as any)
|
||||
outA.length = 0
|
||||
|
||||
ws.data = { events: { connection: "b" } }
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
}
|
||||
// Simulate Bun reusing the same websocket object for another
|
||||
// connection before the next onOpen calls Pty.connect.
|
||||
ws.data = { events: { connection: "b" } }
|
||||
ws.send = (data: unknown) => {
|
||||
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
}
|
||||
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
yield* Effect.promise(() => sleep(100))
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
yield* pty.remove(a.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
await Pty.remove(a.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,40 +104,38 @@ describe("pty", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* pty.create({ command: "cat", title: "a" })
|
||||
try {
|
||||
const out: string[] = []
|
||||
fn: async () => {
|
||||
const a = await Pty.create({ command: "cat", title: "a" })
|
||||
try {
|
||||
const out: string[] = []
|
||||
|
||||
const ctx = { connId: 1 }
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: ctx,
|
||||
send: (data: unknown) => {
|
||||
out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op
|
||||
},
|
||||
}
|
||||
const ctx = { connId: 1 }
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
data: ctx,
|
||||
send: (data: unknown) => {
|
||||
out.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
|
||||
},
|
||||
close: () => {
|
||||
// no-op
|
||||
},
|
||||
}
|
||||
|
||||
yield* pty.connect(a.id, ws as any)
|
||||
out.length = 0
|
||||
Pty.connect(a.id, ws as any)
|
||||
out.length = 0
|
||||
|
||||
ctx.connId = 2
|
||||
// Mutating fields on ws.data should not look like a new
|
||||
// connection lifecycle when the object identity stays stable.
|
||||
ctx.connId = 2
|
||||
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
yield* Effect.promise(() => sleep(100))
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await sleep(100)
|
||||
|
||||
expect(out.join("")).toContain("AAA")
|
||||
} finally {
|
||||
yield* pty.remove(a.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
expect(out.join("")).toContain("AAA")
|
||||
} finally {
|
||||
await Pty.remove(a.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import type { PtyID } from "../../src/pty/schema"
|
||||
@@ -29,37 +27,33 @@ describe("pty", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
fn: async () => {
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = yield* pty.create({
|
||||
command: "/usr/bin/env",
|
||||
args: ["sh", "-c", "sleep 0.1"],
|
||||
title: "sleep",
|
||||
})
|
||||
id = info.id
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = await Pty.create({
|
||||
command: "/usr/bin/env",
|
||||
args: ["sh", "-c", "sleep 0.1"],
|
||||
title: "sleep",
|
||||
})
|
||||
id = info.id
|
||||
|
||||
yield* Effect.promise(() => wait(() => pick(log, id!).includes("exited")))
|
||||
await wait(() => pick(log, id!).includes("exited"))
|
||||
|
||||
yield* pty.remove(id)
|
||||
yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
|
||||
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) yield* pty.remove(id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
await Pty.remove(id)
|
||||
await wait(() => pick(log, id!).length >= 3)
|
||||
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) await Pty.remove(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,33 +64,29 @@ describe("pty", () => {
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
fn: async () => {
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = yield* pty.create({ command: "/bin/sh", title: "sh" })
|
||||
id = info.id
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
|
||||
id = info.id
|
||||
|
||||
yield* Effect.promise(() => sleep(100))
|
||||
await sleep(100)
|
||||
|
||||
yield* pty.remove(id)
|
||||
yield* Effect.promise(() => wait(() => pick(log, id!).length >= 3))
|
||||
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) yield* pty.remove(id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
await Pty.remove(id)
|
||||
await wait(() => pick(log, id!).length >= 3)
|
||||
expect(pick(log, id!)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) await Pty.remove(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
@@ -19,18 +17,14 @@ describe("pty shell args", () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* pty.create({ command: ps, title: "pwsh" })
|
||||
try {
|
||||
expect(info.args).toEqual([])
|
||||
} finally {
|
||||
yield* pty.remove(info.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: ps, title: "pwsh" })
|
||||
try {
|
||||
expect(info.args).toEqual([])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
@@ -49,18 +43,14 @@ describe("pty shell args", () => {
|
||||
await using dir = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* pty.create({ command: bash, title: "bash" })
|
||||
try {
|
||||
expect(info.args).toEqual(["-l"])
|
||||
} finally {
|
||||
yield* pty.remove(info.id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
fn: async () => {
|
||||
const info = await Pty.create({ command: bash, title: "bash" })
|
||||
try {
|
||||
expect(info.args).toEqual(["-l"])
|
||||
} finally {
|
||||
await Pty.remove(info.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
|
||||
@@ -219,59 +219,6 @@ describe("Instruction.resolve", () => {
|
||||
test.todo("fetches remote instructions from config URLs via HttpClient", () => {})
|
||||
})
|
||||
|
||||
describe("Instruction.system", () => {
|
||||
test("loads both project and global AGENTS.md when both exist", async () => {
|
||||
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Project Instructions")
|
||||
},
|
||||
})
|
||||
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: () =>
|
||||
run(
|
||||
Instruction.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const paths = yield* svc.systemPaths()
|
||||
expect(paths.has(path.join(projectTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const rules = yield* svc.system()
|
||||
expect(rules).toHaveLength(2)
|
||||
expect(rules).toContain(
|
||||
`Instructions from: ${path.join(projectTmp.path, "AGENTS.md")}\n# Project Instructions`,
|
||||
)
|
||||
expect(rules).toContain(
|
||||
`Instructions from: ${path.join(globalTmp.path, "AGENTS.md")}\n# Global Instructions`,
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { tool, type ModelMessage } from "ai"
|
||||
import { Cause, Effect, Exit, Stream } from "effect"
|
||||
import { Cause, Exit, Stream } from "effect"
|
||||
import z from "zod"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
@@ -15,16 +15,6 @@ import { tmpdir } from "../fixture/fixture"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.getModel(providerID, modelID)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("session.llm.hasToolCalls", () => {
|
||||
test("returns false for empty messages array", () => {
|
||||
@@ -335,7 +325,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-1")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -426,7 +416,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-raw-abort")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -500,7 +490,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-service-abort")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -591,7 +581,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-tools")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -709,7 +699,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-2")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -829,7 +819,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-data-url")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -952,7 +942,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-3")
|
||||
const agent = {
|
||||
name: "test",
|
||||
@@ -1053,7 +1043,7 @@ describe("session.llm.stream", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-4")
|
||||
const agent = {
|
||||
name: "test",
|
||||
|
||||
@@ -204,7 +204,7 @@ const it = testEffect(makeHttp())
|
||||
const unix = process.platform !== "win32" ? it.live : it.live.skip
|
||||
|
||||
// Config that registers a custom "test" provider with a "test-model" model
|
||||
// so provider model lookup succeeds inside the loop.
|
||||
// so Provider.getModel("test", "test-model") succeeds inside the loop.
|
||||
const cfg = {
|
||||
provider: {
|
||||
test: {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { afterEach, test, expect } from "bun:test"
|
||||
import { Skill } from "../../src/skill"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function createGlobalSkill(homeDir: string) {
|
||||
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
|
||||
@@ -28,29 +26,14 @@ This skill is loaded from the global home directory.
|
||||
)
|
||||
}
|
||||
|
||||
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const prev = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
return prev
|
||||
}),
|
||||
() => self,
|
||||
(prev) =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = prev
|
||||
}),
|
||||
)
|
||||
|
||||
describe("skill", () => {
|
||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
|
||||
`---
|
||||
test("discovers skills from .opencode/skill/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill for verification.
|
||||
---
|
||||
@@ -59,217 +42,230 @@ description: A test skill for verification.
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "test-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("A test skill for verification.")
|
||||
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
const testSkill = skills.find((s) => s.name === "test-skill")
|
||||
expect(testSkill).toBeDefined()
|
||||
expect(testSkill!.description).toBe("A test skill for verification.")
|
||||
expect(testSkill!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("returns skill directories from Skill.dirs", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
withHome(
|
||||
dir,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
|
||||
`---
|
||||
test("returns skill directories from Skill.dirs", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: dir-skill
|
||||
description: Skill for dirs test.
|
||||
---
|
||||
|
||||
# Dir Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const dirs = yield* skill.dirs()
|
||||
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
|
||||
expect(dirs.length).toBe(1)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
it.live("discovers multiple skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
|
||||
`---
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const dirs = await Skill.dirs()
|
||||
const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
|
||||
expect(dirs).toContain(skillDir)
|
||||
expect(dirs.length).toBe(1)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}
|
||||
})
|
||||
|
||||
test("discovers multiple skills from .opencode/skill/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one")
|
||||
const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two")
|
||||
await Bun.write(
|
||||
path.join(skillDir1, "SKILL.md"),
|
||||
`---
|
||||
name: skill-one
|
||||
description: First test skill.
|
||||
---
|
||||
|
||||
# Skill One
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
|
||||
`---
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(skillDir2, "SKILL.md"),
|
||||
`---
|
||||
name: skill-two
|
||||
description: Second test skill.
|
||||
---
|
||||
|
||||
# Skill Two
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(2)
|
||||
expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
|
||||
expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("skips skills with missing frontmatter", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
test("skips skills with missing frontmatter", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
|
||||
Just some content without YAML frontmatter.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
expect(yield* skill.all()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("discovers skills from .claude/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
test("discovers skills from .claude/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "claude-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
yield* withHome(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => createGlobalSkill(tmp.path))
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-test-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
const claudeSkill = skills.find((s) => s.name === "claude-skill")
|
||||
expect(claudeSkill).toBeDefined()
|
||||
expect(claudeSkill!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("returns empty array when no skills exist", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
expect(yield* skill.all()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
test("discovers global skills from ~/.claude/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
it.live("discovers skills from .agents/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
const originalHome = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await createGlobalSkill(tmp.path)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("global-test-skill")
|
||||
expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(skills[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = originalHome
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array when no skills exist", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("discovers skills from .agents/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "agent-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
yield* withHome(
|
||||
tmp.path,
|
||||
Effect.gen(function* () {
|
||||
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
|
||||
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
const agentSkill = skills.find((s) => s.name === "agent-skill")
|
||||
expect(agentSkill).toBeDefined()
|
||||
expect(agentSkill!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("discovers global skills from ~/.agents/skills/ directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const originalHome = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: global-agent-skill
|
||||
description: A global skill from ~/.agents/skills for testing.
|
||||
---
|
||||
@@ -278,114 +274,119 @@ description: A global skill from ~/.agents/skills for testing.
|
||||
|
||||
This skill is loaded from the global home directory.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-agent-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
}),
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("global-agent-skill")
|
||||
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(skills[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = originalHome
|
||||
}
|
||||
})
|
||||
|
||||
test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
|
||||
await Bun.write(
|
||||
path.join(claudeDir, "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
await Bun.write(
|
||||
path.join(agentDir, "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const skills = await Skill.all()
|
||||
expect(skills.length).toBe(2)
|
||||
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
|
||||
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.live("properly resolves directories that skills live in", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
test("properly resolves directories that skills live in", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill")
|
||||
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill")
|
||||
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
|
||||
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
|
||||
await Bun.write(
|
||||
path.join(claudeDir, "SKILL.md"),
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(agentDir, "SKILL.md"),
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(opencodeSkillDir, "SKILL.md"),
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skill directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(opencodeSkillsDir, "SKILL.md"),
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skills directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.dirs()).length).toBe(4)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const dirs = await Skill.dirs()
|
||||
expect(dirs.length).toBe(4)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,152 +1,157 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("tool.registry", () => {
|
||||
it.live("loads tools from .opencode/tool (singular)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tool = path.join(opencode, "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
),
|
||||
)
|
||||
test("loads tools from .opencode/tool (singular)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
it.live("loads tools from .opencode/tools (plural)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
),
|
||||
)
|
||||
const toolDir = path.join(opencodeDir, "tool")
|
||||
await fs.mkdir(toolDir, { recursive: true })
|
||||
|
||||
it.live("loads tools with external dependencies without crashing", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
),
|
||||
await Bun.write(
|
||||
path.join(toolDir, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads tools from .opencode/tools (plural)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const toolsDir = path.join(opencodeDir, "tools")
|
||||
await fs.mkdir(toolsDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(toolsDir, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads tools with external dependencies without crashing", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
|
||||
const toolsDir = path.join(opencodeDir, "tools")
|
||||
await fs.mkdir(toolsDir, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(opencodeDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(opencodeDir, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const cowsay = path.join(opencode, "node_modules", "cowsay")
|
||||
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
),
|
||||
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay")
|
||||
await fs.mkdir(cowsayDir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(cowsayDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
),
|
||||
await Bun.write(
|
||||
path.join(cowsayDir, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
|
||||
await Bun.write(
|
||||
path.join(toolsDir, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ids = await ToolRegistry.ids()
|
||||
expect(ids).toContain("cowsay")
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Skill } from "../../src/skill"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
@@ -12,9 +11,8 @@ import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -30,92 +28,85 @@ afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
|
||||
describe("tool.skill", () => {
|
||||
it.live("description lists skill location URL", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
test("description lists skill location URL", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: tool-skill
|
||||
description: Skill for tool tests.
|
||||
---
|
||||
|
||||
# Tool Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const desc =
|
||||
(yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent: { name: "build", mode: "primary", permission: [], options: {} },
|
||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
||||
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
it.live("description sorts skills by name and is stable across calls", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
for (const [name, description] of [
|
||||
["zeta-skill", "Zeta skill."],
|
||||
["alpha-skill", "Alpha skill."],
|
||||
["middle-skill", "Middle skill."],
|
||||
]) {
|
||||
const skill = path.join(dir, ".opencode", "skill", name)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const desc = await ToolRegistry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent: { name: "build", mode: "primary" as const, permission: [], options: {} },
|
||||
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
|
||||
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}
|
||||
})
|
||||
|
||||
test("description sorts skills by name and is stable across calls", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
for (const [name, description] of [
|
||||
["zeta-skill", "Zeta skill."],
|
||||
["alpha-skill", "Alpha skill."],
|
||||
["middle-skill", "Middle skill."],
|
||||
]) {
|
||||
const skillDir = path.join(dir, ".opencode", "skill", name)
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
---
|
||||
|
||||
# ${name}
|
||||
`,
|
||||
),
|
||||
)
|
||||
}
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = tmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const load = Effect.fnUntraced(function* () {
|
||||
return (
|
||||
(yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
||||
)
|
||||
})
|
||||
const first = yield* load()
|
||||
const second = yield* load()
|
||||
const load = () =>
|
||||
ToolRegistry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
agent,
|
||||
}).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "")
|
||||
const first = await load()
|
||||
const second = await load()
|
||||
|
||||
expect(first).toBe(second)
|
||||
|
||||
@@ -126,10 +117,12 @@ description: ${description}
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(middle).toBeGreaterThan(alpha)
|
||||
expect(zeta).toBeGreaterThan(middle)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
process.env.OPENCODE_TEST_HOME = home
|
||||
}
|
||||
})
|
||||
|
||||
test("execute returns skill content block with files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
|
||||
export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
|
||||
experimental_workspace.register("folder", {
|
||||
name: "Folder",
|
||||
description: "Create a blank folder",
|
||||
configure(config) {
|
||||
const rand = "" + Math.random()
|
||||
|
||||
return {
|
||||
...config,
|
||||
directory: `/tmp/folder/folder-${rand}`,
|
||||
}
|
||||
},
|
||||
async create(config) {
|
||||
if (!config.directory) return
|
||||
await mkdir(config.directory, { recursive: true })
|
||||
},
|
||||
async remove(config) {
|
||||
await rm(config.directory!, { recursive: true, force: true })
|
||||
},
|
||||
target(config) {
|
||||
return {
|
||||
type: "local",
|
||||
directory: config.directory!,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export default FolderWorkspacePlugin
|
||||
@@ -24,44 +24,11 @@ export type ProviderContext = {
|
||||
options: Record<string, any>
|
||||
}
|
||||
|
||||
export type WorkspaceInfo = {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
branch: string | null
|
||||
directory: string | null
|
||||
extra: unknown | null
|
||||
projectID: string
|
||||
}
|
||||
|
||||
export type WorkspaceTarget =
|
||||
| {
|
||||
type: "local"
|
||||
directory: string
|
||||
}
|
||||
| {
|
||||
type: "remote"
|
||||
url: string | URL
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(config: WorkspaceInfo): Promise<void>
|
||||
target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
|
||||
}
|
||||
|
||||
export type PluginInput = {
|
||||
client: ReturnType<typeof createOpencodeClient>
|
||||
project: Project
|
||||
directory: string
|
||||
worktree: string
|
||||
experimental_workspace: {
|
||||
register(type: string, adaptor: WorkspaceAdaptor): void
|
||||
}
|
||||
serverUrl: URL
|
||||
$: BunShell
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
ExperimentalConsoleSwitchOrgResponses,
|
||||
ExperimentalResourceListResponses,
|
||||
ExperimentalSessionListResponses,
|
||||
ExperimentalWorkspaceAdaptorListResponses,
|
||||
ExperimentalWorkspaceCreateErrors,
|
||||
ExperimentalWorkspaceCreateResponses,
|
||||
ExperimentalWorkspaceListResponses,
|
||||
@@ -1087,38 +1086,6 @@ export class Console extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Adaptor extends HeyApiClient {
|
||||
/**
|
||||
* List workspace adaptors
|
||||
*
|
||||
* List all available workspace adaptors for the current project.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
|
||||
url: "/experimental/workspace/adaptor",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Workspace extends HeyApiClient {
|
||||
/**
|
||||
* List workspaces
|
||||
@@ -1262,11 +1229,6 @@ export class Workspace extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
private _adaptor?: Adaptor
|
||||
get adaptor(): Adaptor {
|
||||
return (this._adaptor ??= new Adaptor({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Session extends HeyApiClient {
|
||||
|
||||
@@ -33,13 +33,6 @@ export type EventProjectUpdated = {
|
||||
properties: Project
|
||||
}
|
||||
|
||||
export type EventServerInstanceDisposed = {
|
||||
type: "server.instance.disposed"
|
||||
properties: {
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventInstallationUpdated = {
|
||||
type: "installation.updated"
|
||||
properties: {
|
||||
@@ -54,6 +47,13 @@ export type EventInstallationUpdateAvailable = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerInstanceDisposed = {
|
||||
type: "server.instance.disposed"
|
||||
properties: {
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
@@ -68,21 +68,6 @@ export type EventGlobalDisposed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventLspClientDiagnostics = {
|
||||
type: "lsp.client.diagnostics"
|
||||
properties: {
|
||||
@@ -230,6 +215,107 @@ export type EventSessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileEdited = {
|
||||
type: "file.edited"
|
||||
properties: {
|
||||
file: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventFileWatcherUpdated = {
|
||||
type: "file.watcher.updated"
|
||||
properties: {
|
||||
file: string
|
||||
event: "add" | "change" | "unlink"
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiPromptAppend = {
|
||||
type: "tui.prompt.append"
|
||||
properties: {
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiCommandExecute = {
|
||||
type: "tui.command.execute"
|
||||
properties: {
|
||||
command:
|
||||
| "session.list"
|
||||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
| "session.line.up"
|
||||
| "session.line.down"
|
||||
| "session.half.page.up"
|
||||
| "session.half.page.down"
|
||||
| "session.first"
|
||||
| "session.last"
|
||||
| "prompt.clear"
|
||||
| "prompt.submit"
|
||||
| "agent.cycle"
|
||||
| string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiToastShow = {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
/**
|
||||
* Duration in milliseconds
|
||||
*/
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiSessionSelect = {
|
||||
type: "tui.session.select"
|
||||
properties: {
|
||||
/**
|
||||
* Session ID to navigate to
|
||||
*/
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpToolsChanged = {
|
||||
type: "mcp.tools.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpBrowserOpenFailed = {
|
||||
type: "mcp.browser.open.failed"
|
||||
properties: {
|
||||
mcpName: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventCommandExecuted = {
|
||||
type: "command.executed"
|
||||
properties: {
|
||||
name: string
|
||||
sessionID: string
|
||||
arguments: string
|
||||
messageID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -360,92 +446,6 @@ export type EventSessionCompacted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiPromptAppend = {
|
||||
type: "tui.prompt.append"
|
||||
properties: {
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiCommandExecute = {
|
||||
type: "tui.command.execute"
|
||||
properties: {
|
||||
command:
|
||||
| "session.list"
|
||||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
| "session.line.up"
|
||||
| "session.line.down"
|
||||
| "session.half.page.up"
|
||||
| "session.half.page.down"
|
||||
| "session.first"
|
||||
| "session.last"
|
||||
| "prompt.clear"
|
||||
| "prompt.submit"
|
||||
| "agent.cycle"
|
||||
| string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiToastShow = {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
/**
|
||||
* Duration in milliseconds
|
||||
*/
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type EventTuiSessionSelect = {
|
||||
type: "tui.session.select"
|
||||
properties: {
|
||||
/**
|
||||
* Session ID to navigate to
|
||||
*/
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpToolsChanged = {
|
||||
type: "mcp.tools.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpBrowserOpenFailed = {
|
||||
type: "mcp.browser.open.failed"
|
||||
properties: {
|
||||
mcpName: string
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventCommandExecuted = {
|
||||
type: "command.executed"
|
||||
properties: {
|
||||
name: string
|
||||
sessionID: string
|
||||
arguments: string
|
||||
messageID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventVcsBranchUpdated = {
|
||||
type: "vcs.branch.updated"
|
||||
properties: {
|
||||
branch?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeReady = {
|
||||
type: "worktree.ready"
|
||||
properties: {
|
||||
@@ -973,13 +973,11 @@ export type EventSessionDeleted = {
|
||||
|
||||
export type Event =
|
||||
| EventProjectUpdated
|
||||
| EventServerInstanceDisposed
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventServerInstanceDisposed
|
||||
| EventServerConnected
|
||||
| EventGlobalDisposed
|
||||
| EventFileEdited
|
||||
| EventFileWatcherUpdated
|
||||
| EventLspClientDiagnostics
|
||||
| EventLspUpdated
|
||||
| EventMessagePartDelta
|
||||
@@ -987,13 +985,9 @@ export type Event =
|
||||
| EventPermissionReplied
|
||||
| EventSessionDiff
|
||||
| EventSessionError
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventFileEdited
|
||||
| EventFileWatcherUpdated
|
||||
| EventVcsBranchUpdated
|
||||
| EventTuiPromptAppend
|
||||
| EventTuiCommandExecute
|
||||
| EventTuiToastShow
|
||||
@@ -1001,7 +995,13 @@ export type Event =
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpBrowserOpenFailed
|
||||
| EventCommandExecuted
|
||||
| EventVcsBranchUpdated
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
| EventPtyCreated
|
||||
@@ -1772,8 +1772,8 @@ export type ToolList = Array<ToolListItem>
|
||||
export type Workspace = {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
branch: string | null
|
||||
name: string | null
|
||||
directory: string | null
|
||||
extra: unknown | null
|
||||
projectID: string
|
||||
@@ -2812,30 +2812,6 @@ export type ToolListResponses = {
|
||||
|
||||
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
|
||||
|
||||
export type ExperimentalWorkspaceAdaptorListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/experimental/workspace/adaptor"
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceAdaptorListResponses = {
|
||||
/**
|
||||
* Workspace adaptors
|
||||
*/
|
||||
200: Array<{
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type ExperimentalWorkspaceAdaptorListResponse =
|
||||
ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
|
||||
|
||||
export type ExperimentalWorkspaceListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -1526,62 +1526,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace/adaptor": {
|
||||
"get": {
|
||||
"operationId": "experimental.workspace.adaptor.list",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "List workspace adaptors",
|
||||
"description": "List all available workspace adaptors for the current project.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Workspace adaptors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name", "description"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experimental/workspace": {
|
||||
"post": {
|
||||
"operationId": "experimental.workspace.create",
|
||||
@@ -7286,25 +7230,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.instance.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.instance.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.installation.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7343,6 +7268,25 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.instance.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.instance.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7371,60 +7315,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.lsp.client.diagnostics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7841,6 +7731,264 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.edited": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.edited"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["file"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.file.watcher.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "file.watcher.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"const": "add"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "change"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"const": "unlink"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["file", "event"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.prompt.append": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.prompt.append"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.command.execute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.command.execute"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.share",
|
||||
"session.interrupt",
|
||||
"session.compact",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"prompt.clear",
|
||||
"prompt.submit",
|
||||
"agent.cycle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.toast.show": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.toast.show"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"enum": ["info", "success", "warning", "error"]
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration in milliseconds",
|
||||
"default": 5000,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["message", "variant"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.session.select": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.session.select"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"description": "Session ID to navigate to",
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.mcp.tools.changed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "mcp.tools.changed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.mcp.browser.open.failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "mcp.browser.open.failed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mcpName": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["mcpName", "url"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.command.executed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "command.executed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"arguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
}
|
||||
},
|
||||
"required": ["name", "sessionID", "arguments", "messageID"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8141,210 +8289,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.prompt.append": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.prompt.append"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["text"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.command.execute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.command.execute"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.share",
|
||||
"session.interrupt",
|
||||
"session.compact",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.line.up",
|
||||
"session.line.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"prompt.clear",
|
||||
"prompt.submit",
|
||||
"agent.cycle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.toast.show": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.toast.show"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"enum": ["info", "success", "warning", "error"]
|
||||
},
|
||||
"duration": {
|
||||
"description": "Duration in milliseconds",
|
||||
"default": 5000,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["message", "variant"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.tui.session.select": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "tui.session.select"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"description": "Session ID to navigate to",
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.mcp.tools.changed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "mcp.tools.changed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.mcp.browser.open.failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "mcp.browser.open.failed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mcpName": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["mcpName", "url"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.command.executed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "command.executed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"arguments": {
|
||||
"type": "string"
|
||||
},
|
||||
"messageID": {
|
||||
"type": "string",
|
||||
"pattern": "^msg.*"
|
||||
}
|
||||
},
|
||||
"required": ["name", "sessionID", "arguments", "messageID"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.vcs.branch.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "vcs.branch.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branch": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.worktree.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9930,27 +9874,21 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.project.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.installation.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.installation.update-available"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.global.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
|
||||
},
|
||||
@@ -9973,25 +9911,13 @@
|
||||
"$ref": "#/components/schemas/Event.session.error"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
"$ref": "#/components/schemas/Event.file.edited"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.replied"
|
||||
"$ref": "#/components/schemas/Event.file.watcher.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.idle"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.tui.prompt.append"
|
||||
@@ -10015,7 +9941,25 @@
|
||||
"$ref": "#/components/schemas/Event.command.executed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.vcs.branch.updated"
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.idle"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||
@@ -11941,9 +11885,6 @@
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11954,6 +11895,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -11976,7 +11927,7 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "name", "branch", "directory", "extra", "projectID"]
|
||||
"required": ["id", "type", "branch", "name", "directory", "extra", "projectID"]
|
||||
},
|
||||
"Worktree": {
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user