Merge remote-tracking branch 'origin/v2' into mcp-prompts
# Conflicts: # packages/core/src/mcp/index.ts
This commit is contained in:
commit
dd8e44ad8c
67 changed files with 1970 additions and 984 deletions
2
bun.lock
2
bun.lock
|
|
@ -680,6 +680,7 @@
|
|||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -688,6 +689,7 @@
|
|||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("mcp", {
|
||||
description: "Manage MCP (Model Context Protocol) servers",
|
||||
commands: [
|
||||
Spec.make("list", { description: "List configured MCP servers and their status" }),
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("service", {
|
||||
description: "Manage the background server",
|
||||
|
|
|
|||
58
packages/cli/src/commands/handlers/mcp/add.ts
Normal file
58
packages/cli/src/commands/handlers/mcp/add.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Option } from "effect"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.add,
|
||||
Effect.fn("cli.mcp.add")(function* (input) {
|
||||
const url = Option.getOrUndefined(input.url)
|
||||
const headers = Option.getOrUndefined(input.header)
|
||||
const environment = Option.getOrUndefined(input.env)
|
||||
// The CLI framework strands `--` operands on the root command, so read the local server command
|
||||
// straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`).
|
||||
const dash = process.argv.indexOf("--")
|
||||
const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1)
|
||||
|
||||
const hasCommand = command.length > 0
|
||||
if (url && hasCommand)
|
||||
return yield* Effect.fail(new Error("Provide either --url <url> or a command after --, not both"))
|
||||
if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url <url> or a command after --"))
|
||||
if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`))
|
||||
if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers"))
|
||||
if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers"))
|
||||
|
||||
const server = url
|
||||
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
||||
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
||||
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
|
||||
yield* Effect.promise(() => write(configPath, input.name, server))
|
||||
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
async function resolveConfigPath(directory: string) {
|
||||
const candidates = [
|
||||
path.join(directory, "opencode.json"),
|
||||
path.join(directory, "opencode.jsonc"),
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) return candidate
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function write(configPath: string, name: string, server: unknown) {
|
||||
const file = Bun.file(configPath)
|
||||
const text = (await file.exists()) ? await file.text() : "{}"
|
||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
await Bun.write(configPath, applyEdits(text, edits))
|
||||
}
|
||||
58
packages/cli/src/commands/handlers/mcp/auth.ts
Normal file
58
packages/cli/src/commands/handlers/mcp/auth.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
const method = integration.methods.find(
|
||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
||||
)
|
||||
if (!method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data?.data
|
||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
|
||||
|
||||
const result = yield* poll(client, attempt.attemptID)
|
||||
if (result.status === "complete") {
|
||||
process.stdout.write(`Authenticated with ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
const reason = result.status === "failed" ? `: ${result.message}` : ""
|
||||
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
|
||||
}),
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpencodeClient,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
||||
const status = response.data?.data
|
||||
if (!status || status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, attemptID)
|
||||
}
|
||||
return status
|
||||
})
|
||||
52
packages/cli/src/commands/handlers/mcp/list.ts
Normal file
52
packages/cli/src/commands/handlers/mcp/list.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { EOL } from "node:os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import type { McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
process.stdout.write("No MCP servers configured" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...servers.map((server) => server.name.length))
|
||||
const lines = servers.map(
|
||||
(server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`,
|
||||
)
|
||||
process.stdout.write(lines.join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function icon(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "connected":
|
||||
return "✓"
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
}
|
||||
}
|
||||
|
||||
function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
return status.status
|
||||
}
|
||||
}
|
||||
35
packages/cli/src/commands/handlers/mcp/logout.ts
Normal file
35
packages/cli/src/commands/handlers/mcp/logout.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
||||
}),
|
||||
)
|
||||
17
packages/cli/src/commands/handlers/mcp/resolve.ts
Normal file
17
packages/cli/src/commands/handlers/mcp/resolve.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
|
||||
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
|
||||
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
|
||||
// or anonymous server), leaving that case for the caller to interpret.
|
||||
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
|
||||
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
|
||||
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
||||
const integrationID = server.integrationID
|
||||
if (!integrationID) return undefined
|
||||
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
|
||||
return found.data?.data
|
||||
})
|
||||
|
|
@ -24,6 +24,12 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
add: () => import("./commands/handlers/mcp/add"),
|
||||
auth: () => import("./commands/handlers/mcp/auth"),
|
||||
logout: () => import("./commands/handlers/mcp/logout"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
|
||||
import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import {
|
||||
ClientApi,
|
||||
effectOmitEndpoints,
|
||||
endpointNames,
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
|
|
@ -25,7 +31,11 @@ await Effect.runPromise(
|
|||
emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2, discard: true },
|
||||
{ concurrency: 3, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,57 +1,7 @@
|
|||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||
"@opencode-ai/client/LocationMiddleware",
|
||||
) {}
|
||||
|
||||
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
|
||||
"@opencode-ai/client/SessionLocationMiddleware",
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
|
||||
export const ClientApi = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.location": "location",
|
||||
"server.agent": "agent",
|
||||
"server.session": "session",
|
||||
"server.message": "message",
|
||||
"server.model": "model",
|
||||
"server.generate": "generate",
|
||||
"server.provider": "provider",
|
||||
"server.integration": "integration",
|
||||
"server.credential": "credential",
|
||||
"server.permission": "permission",
|
||||
"server.fs": "file",
|
||||
"server.command": "command",
|
||||
"server.skill": "skill",
|
||||
"server.event": "event",
|
||||
"server.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
"integration.attempt.status": "attemptStatus",
|
||||
"integration.attempt.complete": "attemptComplete",
|
||||
"integration.attempt.cancel": "attemptCancel",
|
||||
"permission.request.list": "listRequests",
|
||||
"permission.saved.list": "listSaved",
|
||||
"permission.saved.remove": "removeSaved",
|
||||
"question.request.list": "listRequests",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||
export {
|
||||
ClientApi,
|
||||
effectOmitEndpoints,
|
||||
endpointNames,
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
|
|
|
|||
|
|
@ -254,9 +254,9 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
|||
skill: Endpoint3_9(raw),
|
||||
compact: Endpoint3_10(raw),
|
||||
wait: Endpoint3_11(raw),
|
||||
stage: Endpoint3_12(raw),
|
||||
clear: Endpoint3_13(raw),
|
||||
commit: Endpoint3_14(raw),
|
||||
revertStage: Endpoint3_12(raw),
|
||||
revertClear: Endpoint3_13(raw),
|
||||
revertCommit: Endpoint3_14(raw),
|
||||
context: Endpoint3_15(raw),
|
||||
history: Endpoint3_16(raw),
|
||||
events: Endpoint3_17(raw),
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ import type {
|
|||
SessionCompactOutput,
|
||||
SessionWaitInput,
|
||||
SessionWaitOutput,
|
||||
SessionStageInput,
|
||||
SessionStageOutput,
|
||||
SessionClearInput,
|
||||
SessionClearOutput,
|
||||
SessionCommitInput,
|
||||
SessionCommitOutput,
|
||||
SessionRevertStageInput,
|
||||
SessionRevertStageOutput,
|
||||
SessionRevertClearInput,
|
||||
SessionRevertClearOutput,
|
||||
SessionRevertCommitInput,
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionHistoryInput,
|
||||
|
|
@ -465,8 +465,8 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
stage: (input: SessionStageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionStageOutput }>(
|
||||
revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionRevertStageOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
|
||||
|
|
@ -477,8 +477,8 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
clear: (input: SessionClearInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionClearOutput>(
|
||||
revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionRevertClearOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
|
||||
|
|
@ -488,8 +488,8 @@ export function make(options: ClientOptions) {
|
|||
},
|
||||
requestOptions,
|
||||
),
|
||||
commit: (input: SessionCommitInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCommitOutput>(
|
||||
revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionRevertCommitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
|
||||
|
|
|
|||
|
|
@ -577,13 +577,13 @@ export type SessionWaitInput = { readonly sessionID: { readonly sessionID: strin
|
|||
|
||||
export type SessionWaitOutput = void
|
||||
|
||||
export type SessionStageInput = {
|
||||
export type SessionRevertStageInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"]
|
||||
readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"]
|
||||
}
|
||||
|
||||
export type SessionStageOutput = {
|
||||
export type SessionRevertStageOutput = {
|
||||
readonly data: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
|
|
@ -599,13 +599,13 @@ export type SessionStageOutput = {
|
|||
}
|
||||
}["data"]
|
||||
|
||||
export type SessionClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
export type SessionRevertClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionClearOutput = void
|
||||
export type SessionRevertClearOutput = void
|
||||
|
||||
export type SessionCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
export type SessionRevertCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionCommitOutput = void
|
||||
export type SessionRevertCommitOutput = void
|
||||
|
||||
export type SessionContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
|
|
@ -2487,6 +2487,7 @@ export type ServerMcpListOutput = {
|
|||
| { readonly status: "failed"; readonly error: string }
|
||||
| { readonly status: "needs_auth" }
|
||||
| { readonly status: "needs_client_registration"; readonly error: string }
|
||||
readonly integrationID?: string
|
||||
}>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export interface Interface extends State.Transformable<Draft> {
|
|||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
|
@ -104,7 +104,7 @@ export const layer = Layer.effect(
|
|||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
all: Effect.fn("AgentV2.all")(function* () {
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const layer = Layer.effect(
|
|||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
|
|||
|
||||
export function replace<A, E, R, E2>(
|
||||
source: Layer.Layer<A, E, R>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, NoInfer<R>> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement {
|
||||
return { source, replacement }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,9 +210,19 @@ export const layer = Layer.effect(
|
|||
const remote = entry.config
|
||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||
const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
|
||||
const suffix =
|
||||
"mcp_" +
|
||||
createHash("sha1")
|
||||
.update(name + "\u0000" + remote.url)
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
entry.integrationID = Integration.ID.make(suffix)
|
||||
registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
|
||||
registrations.push({
|
||||
name,
|
||||
remote,
|
||||
integrationID: entry.integrationID,
|
||||
methodID: Integration.MethodID.make(suffix),
|
||||
})
|
||||
}
|
||||
if (registrations.length > 0)
|
||||
yield* integration.transform((draft) => {
|
||||
|
|
@ -323,6 +333,9 @@ export const layer = Layer.effect(
|
|||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
connection.onClose(() => {
|
||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
||||
if (entry.client !== connection) return
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
|
|
@ -467,7 +480,11 @@ export const layer = Layer.effect(
|
|||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message })))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
),
|
||||
)
|
||||
return new ToolResult({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as PluginV2 from "./plugin"
|
|||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
|
||||
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
|
|
@ -11,17 +11,20 @@ import { CommandV2 } from "./command"
|
|||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { Location } from "./location"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Event = Plugin.Event
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (id: ID, effect: PluginRuntime["effect"]) => Effect.Effect<void>
|
||||
readonly add: (id: ID, effect: PluginDefinition["effect"]) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly wait: (id: ID) => Effect.Effect<void>
|
||||
}
|
||||
|
|
@ -38,9 +41,9 @@ export const layer = Layer.effect(
|
|||
const loading = new Set<ID>()
|
||||
const waiters = new Map<ID, Set<Deferred.Deferred<void>>>()
|
||||
const failures = new Map<ID, Exit.Exit<void, never>>()
|
||||
let host: Parameters<PluginRuntime["effect"]>[0]
|
||||
let host: Parameters<PluginDefinition["effect"]>[0]
|
||||
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginRuntime["effect"]) {
|
||||
const add = Effect.fn("Plugin.add")(function* (id: ID, effect: PluginDefinition["effect"]) {
|
||||
if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
|
||||
|
||||
yield* locks.withLock(id)(
|
||||
|
|
@ -150,6 +153,8 @@ export const locationLayer = layer.pipe(
|
|||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
||||
Layer.provideMerge(PluginRuntime.layer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
|
|
@ -162,7 +167,10 @@ export const node = makeLocationNode({
|
|||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import { Catalog } from "../catalog"
|
|||
import { CommandV2 } from "../command"
|
||||
import { Credential } from "../credential"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import { Reference } from "../reference"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Tool } from "../tool/tool"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
|
||||
|
|
@ -23,12 +28,38 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
const locationRef = (input?: Parameters<Interface["agent"]["list"]>[0]) =>
|
||||
input?.location === undefined
|
||||
? undefined
|
||||
: Location.Ref.make({
|
||||
directory: AbsolutePath.make(input.location.directory ?? location.directory),
|
||||
workspaceID:
|
||||
input.location.workspace === undefined
|
||||
? location.workspaceID
|
||||
: WorkspaceV2.ID.make(input.location.workspace),
|
||||
})
|
||||
const isCurrentLocation = (ref: Location.Ref) =>
|
||||
ref.directory === location.directory && ref.workspaceID === location.workspaceID
|
||||
|
||||
return {
|
||||
options: {},
|
||||
agent: {
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((draft) =>
|
||||
|
|
@ -215,5 +246,21 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||
}),
|
||||
),
|
||||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input as Readonly<Record<string, Tool.AnyTool>>),
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
get: (input) => runtime.session.get(input.sessionID),
|
||||
prompt: runtime.session.prompt,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,13 +20,19 @@ import { FSUtil } from "../fs-util"
|
|||
import { Global } from "../global"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { MCP } from "../mcp"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Reference } from "../reference"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { State } from "../state"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
|
|
@ -36,6 +42,8 @@ import { ProviderPlugins } from "./provider"
|
|||
import { SdkPlugins } from "./sdk"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { VariantPlugin } from "./variant"
|
||||
import { ShellTool } from "../tool/shell"
|
||||
import { SubagentTool } from "../tool/subagent"
|
||||
|
||||
export type Requirements =
|
||||
| AgentV2.Service
|
||||
|
|
@ -49,11 +57,16 @@ export type Requirements =
|
|||
| HttpClient.HttpClient
|
||||
| Integration.Service
|
||||
| Location.Service
|
||||
| LocationMutation.Service
|
||||
| ModelsDev.Service
|
||||
| MCP.Service
|
||||
| Npm.Service
|
||||
| PermissionV2.Service
|
||||
| PluginRuntime.Service
|
||||
| Reference.Service
|
||||
| Shell.Service
|
||||
| SkillV2.Service
|
||||
| Tools.Service
|
||||
|
||||
export interface Plugin<R = never> {
|
||||
readonly id: string
|
||||
|
|
@ -82,8 +95,13 @@ const layer = Layer.effectDiscard(
|
|||
const filesystem = yield* FileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const reference = yield* Reference.Service
|
||||
const shell = yield* Shell.Service
|
||||
const tools = yield* Tools.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const add = <R>(input: Plugin<R>) => {
|
||||
const loaded = {
|
||||
id: input.id,
|
||||
|
|
@ -105,8 +123,13 @@ const layer = Layer.effectDiscard(
|
|||
Effect.provideService(FileSystem.Service, filesystem),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(HttpClient.HttpClient, http),
|
||||
Effect.provideService(LocationMutation.Service, mutation),
|
||||
Effect.provideService(PermissionV2.Service, permission),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(Reference.Service, reference),
|
||||
Effect.provideService(Shell.Service, shell),
|
||||
Effect.provideService(Tools.Service, tools),
|
||||
Effect.provideService(PluginRuntime.Service, runtime),
|
||||
),
|
||||
}
|
||||
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||
|
|
@ -120,6 +143,8 @@ const layer = Layer.effectDiscard(
|
|||
yield* add(SkillPlugin.Plugin)
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigExternalPlugin.Plugin)
|
||||
yield* add(ShellTool.Plugin)
|
||||
yield* add(SubagentTool.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(MCPCommandPlugin.Plugin)
|
||||
|
|
@ -152,6 +177,7 @@ export const node = makeLocationNode({
|
|||
AgentV2.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
MCP.node,
|
||||
Npm.node,
|
||||
|
|
@ -160,8 +186,12 @@ export const node = makeLocationNode({
|
|||
FileSystem.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
PermissionV2.node,
|
||||
SkillV2.node,
|
||||
Reference.node,
|
||||
Shell.node,
|
||||
ToolRegistry.toolsNode,
|
||||
PluginRuntime.node,
|
||||
SdkPlugins.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
117
packages/core/src/plugin/runtime.ts
Normal file
117
packages/core/src/plugin/runtime.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
export * as PluginRuntime from "./runtime"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { Job } from "../job"
|
||||
import { Location } from "../location"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { SessionV2 } from "../session"
|
||||
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
SessionV2.Interface,
|
||||
"get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: AgentV2.Info[] }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
|
||||
|
||||
export interface Cell {
|
||||
runtime?: Interface
|
||||
}
|
||||
|
||||
export const makeCell = (): Cell => ({})
|
||||
|
||||
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
|
||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.suspend(() => {
|
||||
const runtime = cell.runtime
|
||||
if (runtime === undefined) return unavailable<A, E, R>()
|
||||
return f(runtime)
|
||||
})
|
||||
|
||||
const defaultCell = makeCell()
|
||||
|
||||
export const layerWithCell = (cell: Cell) =>
|
||||
Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
session: {
|
||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const providerLayerWithCell = (cell: Cell) =>
|
||||
Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const runtime = {
|
||||
session: sessions,
|
||||
job: jobs,
|
||||
location: {
|
||||
agent: {
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
data: yield* agents.list(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
} satisfies Interface
|
||||
cell.runtime = runtime
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (cell.runtime === runtime) cell.runtime = undefined
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithCell(defaultCell)
|
||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
export const providerNode = makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayer,
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
|
|
@ -4,6 +4,14 @@ import type { Plugin } from "@opencode-ai/plugin/v2/effect"
|
|||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
export interface Store {
|
||||
readonly plugins: Map<string, Plugin>
|
||||
}
|
||||
|
||||
export const makeStore = (): Store => ({ plugins: new Map() })
|
||||
|
||||
const defaultStore = makeStore()
|
||||
|
||||
/**
|
||||
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
|
||||
* so `PluginInternal` can add them on every Location boot through the ordinary
|
||||
|
|
@ -12,9 +20,10 @@ import { makeGlobalNode } from "../effect/app-node"
|
|||
* applies to Locations booted afterward, matching config-plugin timing;
|
||||
* embedders register at startup before creating Sessions.
|
||||
*
|
||||
* State lives in this global-node service (like `ApplicationTools`) rather than
|
||||
* module scope, so the list belongs to one embedded instance and is disposed
|
||||
* with it instead of leaking across `OpenCode.create` calls.
|
||||
* The store is shared explicitly between the SDK construction graph and the
|
||||
* embedded route graph because `LocationServiceMap` builds Location layers lazily
|
||||
* in a nested graph. Each embedded SDK creates its own store, so instances do not
|
||||
* see each other's contributions.
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly register: (plugin: Plugin) => Effect.Effect<void>
|
||||
|
|
@ -23,15 +32,25 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const plugins: Plugin[] = []
|
||||
return Service.of({
|
||||
register: (plugin) => Effect.sync(() => void plugins.push(plugin)),
|
||||
all: () => plugins,
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const layerWithStore = (store: Store) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.clear()
|
||||
}),
|
||||
)
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
store.plugins.set(plugin.id, plugin)
|
||||
}),
|
||||
all: () => [...store.plugins.values()],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWithStore(defaultStore)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ This folder owns Core's one local tool representation, process and Location regi
|
|||
|
||||
## Representations
|
||||
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
|
||||
- `application-tools.ts` stores process-scoped application registrations.
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Shipped built-ins and plugin tools use the same type.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
|
|
@ -29,16 +28,15 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
|||
|
||||
## Registration
|
||||
|
||||
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
|
||||
|
||||
Both are scoped:
|
||||
Registrations are scoped:
|
||||
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Location registrations take precedence over application registrations.
|
||||
- An invocation captures the effective tool once settlement starts.
|
||||
|
||||
`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
|
|
@ -54,6 +52,5 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
|
|||
|
||||
## Current Gaps
|
||||
|
||||
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
export * as ApplicationTools from "./application-tools"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { State } from "../state"
|
||||
import { Tool } from "./tool"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
readonly set: (name: string, entry: Entry) => void
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly identity: object
|
||||
readonly tool: Tool.AnyTool
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, Entry>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
draft: (draft) => ({
|
||||
set: (name, tool) => {
|
||||
draft.entries.set(name, tool)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ApplicationTools.register")(function* (tools) {
|
||||
const entries = Tool.registrationEntries(tools)
|
||||
if (entries.length === 0) return
|
||||
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
|
||||
yield* state.transform((draft) => {
|
||||
for (const [name, entry] of registrations) draft.set(name, entry)
|
||||
})
|
||||
}),
|
||||
entries: () => state.get().entries,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
|
@ -8,7 +8,6 @@ import { SessionMessage } from "../session/message"
|
|||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
|
@ -47,14 +46,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration =
|
||||
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
|
||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
|
|
@ -108,7 +105,7 @@ const registryLayer = Layer.effect(
|
|||
)
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
|
||||
const registrations = new Map(applications.entries())
|
||||
const registrations = new Map<string, Registration>()
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
|
|
@ -143,19 +140,16 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ApplicationTools.layer),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(ToolOutputStore.defaultLayer))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ApplicationTools.node, ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@ export * as ShellTool from "./shell"
|
|||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema, Scope } from "effect"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Job } from "../job"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { Shell } from "../shell"
|
||||
import { Tool, type Content } from "./tool"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
|
|
@ -94,21 +91,22 @@ const externalCommandDirectories = (command: string, cwd: string) => {
|
|||
return [...directories]
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
export const Plugin = {
|
||||
id: "core-shell-tool",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const fsUtil = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
callID: string,
|
||||
command: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: callID }).pipe(
|
||||
yield* runtime.job.wait({ id: callID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
const state =
|
||||
result.info?.status === "completed"
|
||||
|
|
@ -125,7 +123,7 @@ export const layer = Layer.effectDiscard(
|
|||
: state === "error"
|
||||
? (result.info!.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
return sessions.synthetic({
|
||||
return runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
})
|
||||
|
|
@ -134,7 +132,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
|
||||
|
|
@ -154,124 +152,110 @@ export const layer = Layer.effectDiscard(
|
|||
},
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* sessions
|
||||
.get(context.sessionID)
|
||||
.pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })))
|
||||
return yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const shell = yield* Shell.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
|
||||
if (input.background === true) {
|
||||
const run = Effect.fn("ShellTool.run")(function* () {
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout")
|
||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return `${body}${notice}`
|
||||
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
|
||||
})
|
||||
|
||||
const info = yield* jobs.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
if (input.background === true) {
|
||||
const run = Effect.fn("ShellTool.run")(function* () {
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
run: run(),
|
||||
})
|
||||
yield* jobs.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
return yield* Effect.gen(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
if (final.status === "timeout")
|
||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return `${body}${notice}`
|
||||
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
|
||||
})
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
const info = yield* runtime.job.start({
|
||||
id: context.toolCallID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
run: run(),
|
||||
})
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
const final = yield* shell.wait(info.id)
|
||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
||||
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(parent.location)))
|
||||
}
|
||||
|
||||
const truncated = page.size > page.cursor
|
||||
const body = page.output || "(no output)"
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||
return {
|
||||
exit: final.exit,
|
||||
output: `${body}${notice}`,
|
||||
truncated,
|
||||
status: "completed" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
name: "shell-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
export * as SubagentTool from "./subagent"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema, Scope } from "effect"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Job } from "../job"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { SessionV2 } from "../session"
|
||||
import { PluginRuntime } from "../plugin/runtime"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const name = "subagent"
|
||||
|
|
@ -40,18 +37,17 @@ export const description = [
|
|||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
].join("\n")
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const jobs = yield* Job.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
export const Plugin = {
|
||||
id: "core-subagent-tool",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
|
||||
// text" (generic string) from "failed" (the run effect fails, surfaced as a job error).
|
||||
const latestAssistantText = Effect.fn("SubagentTool.latestAssistantText")(function* (sessionID: SessionSchema.ID) {
|
||||
const messages = yield* sessions.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const messages = yield* runtime.session.messages({ sessionID, order: "desc", limit: 20 })
|
||||
const assistant = messages.find(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
|
|
@ -71,7 +67,7 @@ export const layer = Layer.effectDiscard(
|
|||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* sessions.synthetic({
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
})
|
||||
|
|
@ -82,7 +78,7 @@ export const layer = Layer.effectDiscard(
|
|||
childID: SessionSchema.ID,
|
||||
description: string,
|
||||
) {
|
||||
yield* jobs.wait({ id: childID }).pipe(
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, description, "completed", result.info.output ?? NO_TEXT)
|
||||
|
|
@ -96,7 +92,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
})
|
||||
|
||||
yield* tools
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
|
|
@ -105,12 +101,11 @@ export const layer = Layer.effectDiscard(
|
|||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* sessions
|
||||
const parent = yield* runtime.session
|
||||
.get(context.sessionID)
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` })),
|
||||
)
|
||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
const agent = yield* agents.resolve(input.agent)
|
||||
if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` })
|
||||
if (agent.mode === "primary")
|
||||
|
|
@ -118,7 +113,7 @@ export const layer = Layer.effectDiscard(
|
|||
|
||||
// Model selection is policy/config/session state, not an LLM-facing tool argument.
|
||||
const model = agent.model ?? parent.model
|
||||
const child = yield* sessions
|
||||
const child = yield* runtime.session
|
||||
.create({
|
||||
parentID: context.sessionID,
|
||||
title: input.description,
|
||||
|
|
@ -135,12 +130,12 @@ export const layer = Layer.effectDiscard(
|
|||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||
yield* sessions.resume(child.id)
|
||||
yield* runtime.session.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id)))
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
const info = yield* jobs.start({
|
||||
const info = yield* runtime.job.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
|
|
@ -149,18 +144,18 @@ export const layer = Layer.effectDiscard(
|
|||
})
|
||||
|
||||
if (background) {
|
||||
yield* jobs.background(info.id)
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* jobs
|
||||
.block({ id: child.id, sessionID: context.sessionID })
|
||||
.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
|
||||
),
|
||||
)
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], {
|
||||
discard: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
|
|
@ -174,13 +169,4 @@ export const layer = Layer.effectDiscard(
|
|||
})
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
// Registered at the app root via ApplicationTools, not as a Location node: SessionV2 sits above
|
||||
// LocationServiceMap, so a location-scoped subagent node would create a static dependency cycle.
|
||||
// Agent lookup is resolved through the parent Session's location when the tool executes.
|
||||
export const node = makeGlobalNode({
|
||||
name: "subagent-tool",
|
||||
layer,
|
||||
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe("AgentV2", () => {
|
|||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
|
||||
expect(yield* agent.all()).toEqual([])
|
||||
expect(yield* agent.list()).toEqual([])
|
||||
expect(yield* agent.get(AgentV2.ID.make("build"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
|
@ -56,7 +56,7 @@ describe("AgentV2", () => {
|
|||
)
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ id, description: "Reviews code", mode: "subagent" })
|
||||
expect((yield* agent.all()).map((info) => info.id)).toEqual([id])
|
||||
expect((yield* agent.list()).map((info) => info.id)).toEqual([id])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -136,7 +136,7 @@ describe("AgentV2", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const agents = yield* agent.all()
|
||||
const agents = yield* agent.list()
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual([
|
||||
"build",
|
||||
"compaction",
|
||||
|
|
|
|||
|
|
@ -1,288 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const permission = Layer.mock(PermissionV2.Service, {
|
||||
assert: () => Effect.void,
|
||||
})
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_application_tool")
|
||||
const agent = AgentV2.ID.make("build")
|
||||
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
|
||||
const contextual = (contexts: Tool.Context[]) =>
|
||||
Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
return { answer: query.toUpperCase() }
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text", text: output.answer },
|
||||
{ type: "file", data: "aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
})
|
||||
|
||||
describe("ApplicationTools", () => {
|
||||
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const tool = contextual(contexts)
|
||||
expect(Object.keys(tool)).toEqual([])
|
||||
|
||||
yield* applications.register({ opaque: tool })
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "ONCE" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes narrow scoped Location registration and sanitizes names", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools: Tools.Interface = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters an application tool by its name without adding execution authorization", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(
|
||||
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises and executes a scoped application tool with Session context", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([
|
||||
{ name: "application_context", description: "Read application context" },
|
||||
])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "HELLO" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
},
|
||||
output: {
|
||||
structured: { answer: "HELLO" },
|
||||
content: [
|
||||
{ type: "text", text: "HELLO" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "result.png" },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an application tool when its registration scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a tool before settling a call produced from an earlier definition", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registrationScope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
|
||||
yield* Scope.close(registrationScope, Exit.void)
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not leak a registration into an already closed scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an interrupted application registration until its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* applications
|
||||
.register({ interrupted: contextual([]) })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Scope.provide(scope),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("captures the registered record before later State rebuilds", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registered = { stable: contextual([]) }
|
||||
yield* applications.register(registered)
|
||||
Object.assign(registered, { late: contextual([]) })
|
||||
|
||||
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const firstContexts: Tool.Context[] = []
|
||||
const secondContexts: Tool.Context[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual(firstContexts) })
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
|
||||
})
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
|
||||
})
|
||||
|
||||
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the Location tool when an application tool has the same name", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const locationContexts: Tool.Context[] = []
|
||||
const applicationContexts: Tool.Context[] = []
|
||||
const location = contextual(locationContexts)
|
||||
yield* registry.register({ shared: location })
|
||||
yield* applications.register({ shared: contextual(applicationContexts) })
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(applicationContexts).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -17,6 +17,10 @@ const it = testEffect(
|
|||
Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies PermissionV2.Ruleset
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
|
|
@ -77,8 +81,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
|
|
@ -96,8 +99,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
|
|
@ -105,8 +107,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
])
|
||||
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
|
|
@ -264,21 +265,13 @@ Use native v2 fields.`,
|
|||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("loads JSON and JSONC files from lowest to highest priority", () =>
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
@ -203,13 +203,9 @@ describe("Config", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "config.json"),
|
||||
JSON.stringify({ $schema: "base", providers: { base: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ $schema: "middle", providers: { middle: provider } }),
|
||||
JSON.stringify({ $schema: "base", providers: { base: provider } }),
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
|
|
@ -225,12 +221,12 @@ describe("Config", () => {
|
|||
const config = yield* Config.Service
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(3)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "middle", "last"])
|
||||
expect(documents).toHaveLength(2)
|
||||
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
|
||||
expect(documents[0]).toBeInstanceOf(Config.Document)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "config.json"))
|
||||
expect(documents[2]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
|
||||
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ $schema: "changed" })),
|
||||
|
|
@ -239,7 +235,29 @@ describe("Config", () => {
|
|||
(yield* config.entries())
|
||||
.filter((entry) => entry.type === "document")
|
||||
.map((document) => document.info.$schema),
|
||||
).toEqual(["base", "middle", "last"])
|
||||
).toEqual(["base", "last"])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "legacy" })),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
|
||||
|
||||
expect(documents).toHaveLength(0)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
),
|
||||
|
|
@ -681,7 +699,7 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("ignores invalid files while loading valid config values", () =>
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
|
|
@ -690,9 +708,8 @@ describe("Config", () => {
|
|||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "config.json"), JSON.stringify({ $schema: "base" })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), "{ invalid"),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), JSON.stringify({ providers: { invalid: true } })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.json"), JSON.stringify({ $schema: "base" })),
|
||||
fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{ invalid"),
|
||||
]),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
|
|
@ -761,7 +778,7 @@ describe("Config", () => {
|
|||
fs.writeFile(path.join(global, "opencode.json"), JSON.stringify({ $schema: "global" })),
|
||||
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ $schema: "root" })),
|
||||
fs.writeFile(path.join(parent, "opencode.jsonc"), JSON.stringify({ $schema: "parent" })),
|
||||
fs.writeFile(path.join(directory, "config.json"), JSON.stringify({ $schema: "directory" })),
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ $schema: "directory" })),
|
||||
fs.writeFile(path.join(root, ".opencode", "opencode.json"), JSON.stringify({ $schema: "root-dot" })),
|
||||
fs.writeFile(
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const document = path.join(import.meta.dir, "config.json")
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(PluginV2.Service, plugins),
|
||||
|
|
@ -82,7 +82,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "config.json"),
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
{
|
||||
|
|
@ -125,7 +125,7 @@ describe("ConfigExternalPlugin", () => {
|
|||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
path: path.join(import.meta.dir, "config.json"),
|
||||
path: path.join(import.meta.dir, "opencode.json"),
|
||||
info: decode({
|
||||
plugins: [
|
||||
"../plugin/fixtures/missing-plugin.ts",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class OtherError {
|
|||
|
||||
const tags = LayerNode.tags({ app: [] })
|
||||
const make = tags.make("app")
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
|
||||
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root)
|
||||
const aLayer = Layer.succeed(A, A.of({}))
|
||||
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
|
||||
const cLayer = Layer.effect(
|
||||
|
|
@ -32,8 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] })
|
|||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||
|
||||
make({ name: "manual-a", layer: aLayer, deps: [] })
|
||||
|
||||
|
|
@ -51,8 +49,8 @@ make({ service: C, layer: cLayer, deps: [a] })
|
|||
|
||||
const closed = build(LayerNode.group([c]))
|
||||
const closedWithError = build(LayerNode.group([dependent]))
|
||||
const checkClosed: Layer.Layer<C, never, never> = closed
|
||||
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
|
||||
const checkClosed: Layer.Layer<C> = closed
|
||||
const checkError: Layer.Layer<B, LayerError> = closedWithError
|
||||
void checkClosed
|
||||
void checkError
|
||||
|
||||
|
|
@ -64,7 +62,6 @@ LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
|
|||
// @ts-expect-error Replacement cannot introduce a new error
|
||||
LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
|
||||
|
||||
// @ts-expect-error Replacement must be closed
|
||||
LayerNode.replace(bLayer, bLayer)
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import fs from "fs/promises"
|
|||
import { tmpdir as osTmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
export const tmpdir = async () => {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), "opencode-core-test-")))
|
||||
export const tmpdir = async (prefix = "opencode-core-test-") => {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(osTmpdir(), prefix)))
|
||||
return {
|
||||
path: dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,22 @@ export const toolDefinitions = (
|
|||
model = testModel,
|
||||
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
|
||||
|
||||
export function waitForTool(
|
||||
registry: ToolRegistry.Interface,
|
||||
name: string,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<void, Error> {
|
||||
return Effect.gen(function* () {
|
||||
if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return
|
||||
if (remaining === 0) {
|
||||
yield* Effect.fail(new Error(`Timed out waiting for tool: ${name}`))
|
||||
return
|
||||
}
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
yield* waitForTool(registry, name, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
|
||||
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import fs from "fs/promises"
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
|
|
@ -17,7 +16,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { FSUtil } from "../src/fs-util"
|
||||
import { Credential } from "../src/credential"
|
||||
import { Database } from "../src/database/database"
|
||||
|
|
@ -28,14 +27,11 @@ import { Npm } from "../src/npm"
|
|||
import { Project } from "../src/project"
|
||||
import { Reference } from "../src/reference"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer),
|
||||
locationServiceMapLayer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
|
|
@ -83,14 +79,6 @@ describe("LocationServiceMap", () => {
|
|||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* (yield* ApplicationTools.Service).register({
|
||||
application_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
|
|
@ -105,9 +93,12 @@ describe("LocationServiceMap", () => {
|
|||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "shell")
|
||||
yield* waitForTool(registry, "subagent")
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
|
||||
tools: yield* toolDefinitions(registry),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
|
@ -119,13 +110,14 @@ describe("LocationServiceMap", () => {
|
|||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"application_context",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
|
|
@ -134,13 +126,14 @@ describe("LocationServiceMap", () => {
|
|||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"application_context",
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"todowrite",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { testModel } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
|
@ -68,4 +71,35 @@ describe("PluginV2", () => {
|
|||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers location tools through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const plugin = define({
|
||||
id: "tool-plugin",
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.register({
|
||||
plugin_tool: Tool.make({
|
||||
description: "Plugin tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
|
||||
yield* plugins.remove(PluginV2.ID.make(plugin.id))
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_tool",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
return {
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
transform: () => Effect.die("unused agent.transform"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
},
|
||||
|
|
@ -49,11 +50,21 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
transform: () => Effect.die("unused skill.transform"),
|
||||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
register: () => Effect.die("unused tool.register"),
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
get: () => Effect.die("unused session.get"),
|
||||
prompt: () => Effect.die("unused session.prompt"),
|
||||
interrupt: () => Effect.die("unused session.interrupt"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
|
||||
return {
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: agent.reload,
|
||||
transform: (callback) =>
|
||||
agent.transform((draft) =>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
|||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import type { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
|
|
@ -28,9 +27,8 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
|||
)
|
||||
},
|
||||
})
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(outputStore))
|
||||
const it = testEffect(registry)
|
||||
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
|
|
@ -399,38 +397,6 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects an application call after a Location override is registered", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* service.register({ echo: make() })
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
integrated.effect("rejects a Location call after removal reveals an application registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* applications.register({ echo: make() })
|
||||
const scope = yield* Scope.make()
|
||||
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps captured execution running after registration mutation", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
|
|
@ -124,12 +123,7 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(ToolOutputStore.defaultLayer))
|
||||
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
const echo = Layer.effectDiscard(
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
|
|
@ -286,7 +280,6 @@ const it = testEffect(
|
|||
SessionStore.defaultLayer,
|
||||
client,
|
||||
permission,
|
||||
applications,
|
||||
agents,
|
||||
registry,
|
||||
echo,
|
||||
|
|
@ -582,14 +575,14 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
|||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("advertises and executes a globally attached application tool", () =>
|
||||
it.effect("advertises and executes a location registered tool", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const applicationTools = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applicationTools.register({
|
||||
application_context: Tool.make({
|
||||
yield* registry.register({
|
||||
location_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
|
|
@ -604,7 +597,7 @@ describe("SessionRunnerLLM", () => {
|
|||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-application", name: "application_context", input: { query: "hello" } }),
|
||||
LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
|
|
@ -613,13 +606,13 @@ describe("SessionRunnerLLM", () => {
|
|||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("application_context")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("location_context")
|
||||
expect(contexts).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: expect.stringMatching(/^msg_/),
|
||||
toolCallID: "call-application",
|
||||
toolCallID: "call-location",
|
||||
},
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
|
|
@ -629,7 +622,7 @@ describe("SessionRunnerLLM", () => {
|
|||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-application",
|
||||
id: "call-location",
|
||||
state: { status: "completed", structured: { answer: "HELLO" } },
|
||||
},
|
||||
],
|
||||
|
|
@ -914,10 +907,7 @@ describe("SessionRunnerLLM", () => {
|
|||
response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
"Build agent instructions",
|
||||
"Initial context",
|
||||
])
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -122,7 +123,7 @@ const layer = AppNodeBuilder.build(
|
|||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
ShellTool.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
|
|
@ -167,6 +168,7 @@ const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.I
|
|||
const locations = yield* LocationServiceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry).pipe(Effect.provide(locationLayer))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool"
|
||||
import { executeTool, settleTool, testModel, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
|
||||
|
|
@ -98,7 +99,7 @@ const layer = AppNodeBuilder.build(
|
|||
Job.node,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
SubagentTool.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
SessionExecution.node,
|
||||
|
|
@ -142,6 +143,7 @@ describe("SubagentTool", () => {
|
|||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
|
||||
SubagentTool.name,
|
||||
)
|
||||
|
|
@ -175,6 +177,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
@ -226,6 +229,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
|
|
@ -257,6 +261,7 @@ describe("SubagentTool", () => {
|
|||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* settleTool(registry, {
|
||||
sessionID: parent.id,
|
||||
|
|
|
|||
|
|
@ -244,6 +244,16 @@ export function emitEffectImported(
|
|||
}
|
||||
}
|
||||
|
||||
export function emitEffectShape(
|
||||
contract: Contract,
|
||||
options: { readonly module: string; readonly api: string },
|
||||
): Output {
|
||||
return {
|
||||
operations: operations(contract.groups),
|
||||
files: [{ path: "api.ts", content: renderEffectShape(contract.groups, options) }],
|
||||
}
|
||||
}
|
||||
|
||||
export function emitPromise(
|
||||
contract: Contract,
|
||||
options?: {
|
||||
|
|
@ -275,6 +285,75 @@ export function emitPromise(
|
|||
}
|
||||
}
|
||||
|
||||
function renderEffectShape(groups: ReadonlyArray<Group>, options: { readonly module: string; readonly api: string }) {
|
||||
const endpointTypes = groups.map((group, groupIndex) => {
|
||||
const rawGroup = group.endpoints[0]?.topLevel ? "RawClient" : `RawClient[${JSON.stringify(group.sourceIdentifier)}]`
|
||||
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
|
||||
const prefix = `Endpoint${groupIndex}_${endpointIndex}`
|
||||
const request =
|
||||
endpoint.operation.inputMode === "none"
|
||||
? ""
|
||||
: `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(endpoint.endpoint.name)}]>[0]`
|
||||
const input = endpoint.input
|
||||
.map(
|
||||
(field) =>
|
||||
`readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: ${prefix}Request[${JSON.stringify(field.source)}][${JSON.stringify(field.name)}]`,
|
||||
)
|
||||
.join("; ")
|
||||
const inputType = endpoint.operation.inputMode === "none" ? "" : `export type ${prefix}Input = { ${input} }`
|
||||
const rawOutput = `EffectValue<ReturnType<${rawGroup}[${JSON.stringify(endpoint.endpoint.name)}]>>`
|
||||
const outputType = isStreamSchema(endpoint.successes[0])
|
||||
? `export type ${prefix}Output = StreamValue<${rawOutput}>`
|
||||
: `export type ${prefix}Output = ${endpoint.unwrapData ? `(${rawOutput})["data"]` : rawOutput}`
|
||||
return [
|
||||
request,
|
||||
endpoint.operation.inputMode === "none" ? "" : inputType,
|
||||
outputType,
|
||||
`export type ${groupShapeTypeName(group, endpoint)}<E = never> = (${endpoint.operation.inputMode === "none" ? "" : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input`}) => ${endpoint.operation.success === "stream" ? `Stream.Stream<${prefix}Output, E>` : `Effect.Effect<${prefix}Output, E>`}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
})
|
||||
const methods = group.endpoints
|
||||
.map(
|
||||
(endpoint) => `readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}<E>`,
|
||||
)
|
||||
.join("\n")
|
||||
return `${endpoints.join("\n\n")}\n\nexport interface ${groupShapeName(group)}<E = never> {\n${methods}\n}`
|
||||
})
|
||||
const clientFields = groups.flatMap((group) =>
|
||||
group.endpoints[0]?.topLevel
|
||||
? group.endpoints.map(
|
||||
(endpoint) =>
|
||||
`readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}<E>`,
|
||||
)
|
||||
: [`readonly ${JSON.stringify(group.identifier)}: ${groupShapeName(group)}<E>`],
|
||||
)
|
||||
return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import type { Effect, Stream } from "effect"
|
||||
import type { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import type { ${options.api} } from ${JSON.stringify(options.module)}
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ${options.api}>
|
||||
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
|
||||
type StreamValue<A> = A extends Stream.Stream<infer Success, any, any> ? Success : never
|
||||
|
||||
${endpointTypes.join("\n\n")}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
${clientFields.join("\n")}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function groupShapeName(group: Group) {
|
||||
return `${identifierPart(group.identifier)}Api`
|
||||
}
|
||||
|
||||
function groupShapeTypeName(group: Group, endpoint: Endpoint) {
|
||||
return `${identifierPart(group.identifier)}${identifierPart(endpoint.operation.name)}Operation`
|
||||
}
|
||||
|
||||
function assertPromiseEndpoint(endpoint: Endpoint) {
|
||||
const name = `${endpoint.group}.${endpoint.endpoint.name}`
|
||||
const payload = endpoint.payloads[0]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
],
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:"
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Plugin } from "./index.js"
|
||||
import type { Plugin } from "./index.js"
|
||||
import { tool } from "./tool.js"
|
||||
|
||||
export const ExamplePlugin: Plugin = async (_ctx) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AgentV2Info } from "@opencode-ai/sdk/v2/types"
|
||||
import type { AgentApi } from "./generated/api.js"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface AgentDraft {
|
||||
|
|
@ -12,3 +13,6 @@ export interface AgentDraft {
|
|||
export type AgentHooks = Hooks<{
|
||||
transform: AgentDraft
|
||||
}>
|
||||
|
||||
export type AgentPluginApi = AgentHooks
|
||||
export type AgentDomain = AgentApi & AgentPluginApi
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PluginOptions } from "../options.js"
|
||||
import type { AgentHooks } from "./agent.js"
|
||||
import type { AgentDomain } from "./agent.js"
|
||||
import type { AISDKHooks } from "./aisdk.js"
|
||||
import type { CatalogHooks } from "./catalog.js"
|
||||
import type { CommandHooks } from "./command.js"
|
||||
|
|
@ -8,10 +8,12 @@ import type { PluginDomain } from "./plugin.js"
|
|||
import type { ReferenceHooks } from "./reference.js"
|
||||
import type { SkillHooks } from "./skill.js"
|
||||
import type { Reload } from "./registration.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { SessionDomain } from "./runtime.js"
|
||||
|
||||
export interface PluginContext {
|
||||
readonly options: PluginOptions
|
||||
readonly agent: AgentHooks & Reload
|
||||
readonly agent: AgentDomain & Reload
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly catalog: CatalogHooks & Reload
|
||||
readonly command: CommandHooks & Reload
|
||||
|
|
@ -19,4 +21,6 @@ export interface PluginContext {
|
|||
readonly plugin: PluginDomain
|
||||
readonly reference: ReferenceHooks & Reload
|
||||
readonly skill: SkillHooks & Reload
|
||||
readonly tool: ToolDomain
|
||||
readonly session: SessionDomain
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
[
|
||||
"api.ts"
|
||||
]
|
||||
705
packages/plugin/src/v2/effect/generated/api.ts
Normal file
705
packages/plugin/src/v2/effect/generated/api.ts
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
|
||||
import type { Effect, Stream } from "effect"
|
||||
import type { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import type { ClientApi } from "@opencode-ai/protocol/client"
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
|
||||
type StreamValue<A> = A extends Stream.Stream<infer Success, any, any> ? Success : never
|
||||
|
||||
export type Endpoint0_0Output = EffectValue<ReturnType<RawClient["server.health"]["health.get"]>>
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
|
||||
export interface HealthApi<E = never> {
|
||||
readonly get: HealthGetOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
|
||||
export type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
|
||||
export type Endpoint1_0Output = EffectValue<ReturnType<RawClient["server.location"]["location.get"]>>
|
||||
export type LocationGetOperation<E = never> = (input?: Endpoint1_0Input) => Effect.Effect<Endpoint1_0Output, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
|
||||
export type Endpoint2_0Output = EffectValue<ReturnType<RawClient["server.agent"]["agent.list"]>>
|
||||
export type AgentListOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||
|
||||
export interface AgentApi<E = never> {
|
||||
readonly list: AgentListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
export type Endpoint3_0Input = {
|
||||
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint3_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint3_0Request["query"]["order"]
|
||||
readonly search?: Endpoint3_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint3_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint3_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
|
||||
}
|
||||
export type Endpoint3_0Output = EffectValue<ReturnType<RawClient["server.session"]["session.list"]>>
|
||||
export type SessionListOperation<E = never> = (input?: Endpoint3_0Input) => Effect.Effect<Endpoint3_0Output, E>
|
||||
|
||||
type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
export type Endpoint3_1Input = {
|
||||
readonly id?: Endpoint3_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint3_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint3_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint3_1Request["payload"]["location"]
|
||||
}
|
||||
export type Endpoint3_1Output = EffectValue<ReturnType<RawClient["server.session"]["session.create"]>>["data"]
|
||||
export type SessionCreateOperation<E = never> = (input?: Endpoint3_1Input) => Effect.Effect<Endpoint3_1Output, E>
|
||||
|
||||
export type Endpoint3_2Output = EffectValue<ReturnType<RawClient["server.session"]["session.active"]>>["data"]
|
||||
export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint3_2Output, E>
|
||||
|
||||
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
export type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
|
||||
export type Endpoint3_3Output = EffectValue<ReturnType<RawClient["server.session"]["session.get"]>>["data"]
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint3_3Input) => Effect.Effect<Endpoint3_3Output, E>
|
||||
|
||||
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.fork"]>[0]
|
||||
export type Endpoint3_4Input = {
|
||||
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
|
||||
readonly messageID?: Endpoint3_4Request["payload"]["messageID"]
|
||||
}
|
||||
export type Endpoint3_4Output = EffectValue<ReturnType<RawClient["server.session"]["session.fork"]>>["data"]
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint3_4Input) => Effect.Effect<Endpoint3_4Output, E>
|
||||
|
||||
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
export type Endpoint3_5Input = {
|
||||
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint3_5Request["payload"]["agent"]
|
||||
}
|
||||
export type Endpoint3_5Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchAgent"]>>
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint3_5Input) => Effect.Effect<Endpoint3_5Output, E>
|
||||
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
export type Endpoint3_6Input = {
|
||||
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||
readonly model: Endpoint3_6Request["payload"]["model"]
|
||||
}
|
||||
export type Endpoint3_6Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchModel"]>>
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint3_6Input) => Effect.Effect<Endpoint3_6Output, E>
|
||||
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
|
||||
export type Endpoint3_7Input = {
|
||||
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
|
||||
readonly title: Endpoint3_7Request["payload"]["title"]
|
||||
}
|
||||
export type Endpoint3_7Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>>
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint3_7Input) => Effect.Effect<Endpoint3_7Output, E>
|
||||
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
export type Endpoint3_8Input = {
|
||||
readonly sessionID: Endpoint3_8Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_8Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_8Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_8Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_8Request["payload"]["resume"]
|
||||
}
|
||||
export type Endpoint3_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint3_8Input) => Effect.Effect<Endpoint3_8Output, E>
|
||||
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
export type Endpoint3_9Input = {
|
||||
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_9Request["payload"]["id"]
|
||||
readonly skill: Endpoint3_9Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint3_9Request["payload"]["resume"]
|
||||
}
|
||||
export type Endpoint3_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint3_9Input) => Effect.Effect<Endpoint3_9Output, E>
|
||||
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
export type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||
export type Endpoint3_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint3_10Input) => Effect.Effect<Endpoint3_10Output, E>
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
export type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
export type Endpoint3_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint3_11Input) => Effect.Effect<Endpoint3_11Output, E>
|
||||
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
export type Endpoint3_12Input = {
|
||||
readonly sessionID: Endpoint3_12Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_12Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_12Request["payload"]["files"]
|
||||
}
|
||||
export type Endpoint3_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint3_12Input) => Effect.Effect<Endpoint3_12Output, E>
|
||||
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
export type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
export type Endpoint3_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint3_13Input) => Effect.Effect<Endpoint3_13Output, E>
|
||||
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
export type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
|
||||
export type Endpoint3_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint3_14Input) => Effect.Effect<Endpoint3_14Output, E>
|
||||
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
export type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||
export type Endpoint3_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint3_15Input) => Effect.Effect<Endpoint3_15Output, E>
|
||||
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
export type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_16Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_16Request["query"]["after"]
|
||||
}
|
||||
export type Endpoint3_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
|
||||
export type SessionHistoryOperation<E = never> = (input: Endpoint3_16Input) => Effect.Effect<Endpoint3_16Output, E>
|
||||
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
export type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_17Request["query"]["after"]
|
||||
}
|
||||
export type Endpoint3_17Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
||||
export type SessionEventsOperation<E = never> = (input: Endpoint3_17Input) => Stream.Stream<Endpoint3_17Output, E>
|
||||
|
||||
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
export type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] }
|
||||
export type Endpoint3_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint3_18Input) => Effect.Effect<Endpoint3_18Output, E>
|
||||
|
||||
type Endpoint3_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
export type Endpoint3_19Input = {
|
||||
readonly sessionID: Endpoint3_19Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_19Request["params"]["messageID"]
|
||||
}
|
||||
export type Endpoint3_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint3_19Input) => Effect.Effect<Endpoint3_19Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly rename: SessionRenameOperation<E>
|
||||
readonly prompt: SessionPromptOperation<E>
|
||||
readonly skill: SessionSkillOperation<E>
|
||||
readonly compact: SessionCompactOperation<E>
|
||||
readonly wait: SessionWaitOperation<E>
|
||||
readonly revertStage: SessionRevertStageOperation<E>
|
||||
readonly revertClear: SessionRevertClearOperation<E>
|
||||
readonly revertCommit: SessionRevertCommitOperation<E>
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly history: SessionHistoryOperation<E>
|
||||
readonly events: SessionEventsOperation<E>
|
||||
readonly interrupt: SessionInterruptOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
export type Endpoint4_0Input = {
|
||||
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint4_0Request["query"]["order"]
|
||||
readonly cursor?: Endpoint4_0Request["query"]["cursor"]
|
||||
}
|
||||
export type Endpoint4_0Output = EffectValue<ReturnType<RawClient["server.message"]["session.messages"]>>
|
||||
export type MessageListOperation<E = never> = (input: Endpoint4_0Input) => Effect.Effect<Endpoint4_0Output, E>
|
||||
|
||||
export interface MessageApi<E = never> {
|
||||
readonly list: MessageListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
|
||||
export type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
|
||||
export type Endpoint5_0Output = EffectValue<ReturnType<RawClient["server.model"]["model.list"]>>
|
||||
export type ModelListOperation<E = never> = (input?: Endpoint5_0Input) => Effect.Effect<Endpoint5_0Output, E>
|
||||
|
||||
export interface ModelApi<E = never> {
|
||||
readonly list: ModelListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint6_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||
export type Endpoint6_0Input = {
|
||||
readonly location?: Endpoint6_0Request["query"]["location"]
|
||||
readonly prompt: Endpoint6_0Request["payload"]["prompt"]
|
||||
readonly model?: Endpoint6_0Request["payload"]["model"]
|
||||
}
|
||||
export type Endpoint6_0Output = EffectValue<ReturnType<RawClient["server.generate"]["generate.text"]>>["data"]
|
||||
export type GenerateTextOperation<E = never> = (input: Endpoint6_0Input) => Effect.Effect<Endpoint6_0Output, E>
|
||||
|
||||
export interface GenerateApi<E = never> {
|
||||
readonly text: GenerateTextOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
|
||||
export type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
|
||||
export type Endpoint7_0Output = EffectValue<ReturnType<RawClient["server.provider"]["provider.list"]>>
|
||||
export type ProviderListOperation<E = never> = (input?: Endpoint7_0Input) => Effect.Effect<Endpoint7_0Output, E>
|
||||
|
||||
type Endpoint7_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
|
||||
export type Endpoint7_1Input = {
|
||||
readonly providerID: Endpoint7_1Request["params"]["providerID"]
|
||||
readonly location?: Endpoint7_1Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint7_1Output = EffectValue<ReturnType<RawClient["server.provider"]["provider.get"]>>
|
||||
export type ProviderGetOperation<E = never> = (input: Endpoint7_1Input) => Effect.Effect<Endpoint7_1Output, E>
|
||||
|
||||
export interface ProviderApi<E = never> {
|
||||
readonly list: ProviderListOperation<E>
|
||||
readonly get: ProviderGetOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint8_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
|
||||
export type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] }
|
||||
export type Endpoint8_0Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.list"]>>
|
||||
export type IntegrationListOperation<E = never> = (input?: Endpoint8_0Input) => Effect.Effect<Endpoint8_0Output, E>
|
||||
|
||||
type Endpoint8_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
|
||||
export type Endpoint8_1Input = {
|
||||
readonly integrationID: Endpoint8_1Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_1Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint8_1Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.get"]>>
|
||||
export type IntegrationGetOperation<E = never> = (input: Endpoint8_1Input) => Effect.Effect<Endpoint8_1Output, E>
|
||||
|
||||
type Endpoint8_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
export type Endpoint8_2Input = {
|
||||
readonly integrationID: Endpoint8_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_2Request["query"]["location"]
|
||||
readonly key: Endpoint8_2Request["payload"]["key"]
|
||||
readonly label?: Endpoint8_2Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint8_2Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.key"]>>
|
||||
export type IntegrationConnectKeyOperation<E = never> = (input: Endpoint8_2Input) => Effect.Effect<Endpoint8_2Output, E>
|
||||
|
||||
type Endpoint8_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
export type Endpoint8_3Input = {
|
||||
readonly integrationID: Endpoint8_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_3Request["query"]["location"]
|
||||
readonly methodID: Endpoint8_3Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint8_3Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint8_3Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint8_3Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.connect.oauth"]>>
|
||||
export type IntegrationConnectOauthOperation<E = never> = (
|
||||
input: Endpoint8_3Input,
|
||||
) => Effect.Effect<Endpoint8_3Output, E>
|
||||
|
||||
type Endpoint8_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
export type Endpoint8_4Input = {
|
||||
readonly attemptID: Endpoint8_4Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint8_4Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.status"]>>
|
||||
export type IntegrationAttemptStatusOperation<E = never> = (
|
||||
input: Endpoint8_4Input,
|
||||
) => Effect.Effect<Endpoint8_4Output, E>
|
||||
|
||||
type Endpoint8_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
export type Endpoint8_5Input = {
|
||||
readonly attemptID: Endpoint8_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_5Request["query"]["location"]
|
||||
readonly code?: Endpoint8_5Request["payload"]["code"]
|
||||
}
|
||||
export type Endpoint8_5Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.complete"]>>
|
||||
export type IntegrationAttemptCompleteOperation<E = never> = (
|
||||
input: Endpoint8_5Input,
|
||||
) => Effect.Effect<Endpoint8_5Output, E>
|
||||
|
||||
type Endpoint8_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
export type Endpoint8_6Input = {
|
||||
readonly attemptID: Endpoint8_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_6Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint8_6Output = EffectValue<ReturnType<RawClient["server.integration"]["integration.attempt.cancel"]>>
|
||||
export type IntegrationAttemptCancelOperation<E = never> = (
|
||||
input: Endpoint8_6Input,
|
||||
) => Effect.Effect<Endpoint8_6Output, E>
|
||||
|
||||
export interface IntegrationApi<E = never> {
|
||||
readonly list: IntegrationListOperation<E>
|
||||
readonly get: IntegrationGetOperation<E>
|
||||
readonly connectKey: IntegrationConnectKeyOperation<E>
|
||||
readonly connectOauth: IntegrationConnectOauthOperation<E>
|
||||
readonly attemptStatus: IntegrationAttemptStatusOperation<E>
|
||||
readonly attemptComplete: IntegrationAttemptCompleteOperation<E>
|
||||
readonly attemptCancel: IntegrationAttemptCancelOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint9_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
export type Endpoint9_0Input = {
|
||||
readonly credentialID: Endpoint9_0Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint9_0Request["query"]["location"]
|
||||
readonly label: Endpoint9_0Request["payload"]["label"]
|
||||
}
|
||||
export type Endpoint9_0Output = EffectValue<ReturnType<RawClient["server.credential"]["credential.update"]>>
|
||||
export type CredentialUpdateOperation<E = never> = (input: Endpoint9_0Input) => Effect.Effect<Endpoint9_0Output, E>
|
||||
|
||||
type Endpoint9_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
|
||||
export type Endpoint9_1Input = {
|
||||
readonly credentialID: Endpoint9_1Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint9_1Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint9_1Output = EffectValue<ReturnType<RawClient["server.credential"]["credential.remove"]>>
|
||||
export type CredentialRemoveOperation<E = never> = (input: Endpoint9_1Input) => Effect.Effect<Endpoint9_1Output, E>
|
||||
|
||||
export interface CredentialApi<E = never> {
|
||||
readonly update: CredentialUpdateOperation<E>
|
||||
readonly remove: CredentialRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint10_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
|
||||
export type Endpoint10_0Output = EffectValue<ReturnType<RawClient["server.project"]["project.current"]>>
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint10_0Input) => Effect.Effect<Endpoint10_0Output, E>
|
||||
|
||||
type Endpoint10_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
export type Endpoint10_1Input = {
|
||||
readonly projectID: Endpoint10_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint10_1Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.project"]["project.directories"]>>
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
|
||||
export type PermissionListRequestsOperation<E = never> = (
|
||||
input?: Endpoint11_0Input,
|
||||
) => Effect.Effect<Endpoint11_0Output, E>
|
||||
|
||||
type Endpoint11_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
export type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] }
|
||||
export type Endpoint11_1Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["permission.saved.list"]>
|
||||
>["data"]
|
||||
export type PermissionListSavedOperation<E = never> = (
|
||||
input?: Endpoint11_1Input,
|
||||
) => Effect.Effect<Endpoint11_1Output, E>
|
||||
|
||||
type Endpoint11_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
export type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] }
|
||||
export type Endpoint11_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
|
||||
export type PermissionRemoveSavedOperation<E = never> = (
|
||||
input: Endpoint11_2Input,
|
||||
) => Effect.Effect<Endpoint11_2Output, E>
|
||||
|
||||
type Endpoint11_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
export type Endpoint11_3Input = {
|
||||
readonly sessionID: Endpoint11_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint11_3Request["payload"]["id"]
|
||||
readonly action: Endpoint11_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint11_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint11_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint11_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint11_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint11_3Request["payload"]["agent"]
|
||||
}
|
||||
export type Endpoint11_3Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.create"]>
|
||||
>["data"]
|
||||
export type PermissionCreateOperation<E = never> = (input: Endpoint11_3Input) => Effect.Effect<Endpoint11_3Output, E>
|
||||
|
||||
type Endpoint11_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
export type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] }
|
||||
export type Endpoint11_4Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.list"]>
|
||||
>["data"]
|
||||
export type PermissionListOperation<E = never> = (input: Endpoint11_4Input) => Effect.Effect<Endpoint11_4Output, E>
|
||||
|
||||
type Endpoint11_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
export type Endpoint11_5Input = {
|
||||
readonly sessionID: Endpoint11_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint11_5Request["params"]["requestID"]
|
||||
}
|
||||
export type Endpoint11_5Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.get"]>
|
||||
>["data"]
|
||||
export type PermissionGetOperation<E = never> = (input: Endpoint11_5Input) => Effect.Effect<Endpoint11_5Output, E>
|
||||
|
||||
type Endpoint11_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
export type Endpoint11_6Input = {
|
||||
readonly sessionID: Endpoint11_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint11_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint11_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint11_6Request["payload"]["message"]
|
||||
}
|
||||
export type Endpoint11_6Output = EffectValue<ReturnType<RawClient["server.permission"]["session.permission.reply"]>>
|
||||
export type PermissionReplyOperation<E = never> = (input: Endpoint11_6Input) => Effect.Effect<Endpoint11_6Output, E>
|
||||
|
||||
export interface PermissionApi<E = never> {
|
||||
readonly listRequests: PermissionListRequestsOperation<E>
|
||||
readonly listSaved: PermissionListSavedOperation<E>
|
||||
readonly removeSaved: PermissionRemoveSavedOperation<E>
|
||||
readonly create: PermissionCreateOperation<E>
|
||||
readonly list: PermissionListOperation<E>
|
||||
readonly get: PermissionGetOperation<E>
|
||||
readonly reply: PermissionReplyOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
export type Endpoint12_0Input = {
|
||||
readonly location?: Endpoint12_0Request["query"]["location"]
|
||||
readonly path?: Endpoint12_0Request["query"]["path"]
|
||||
}
|
||||
export type Endpoint12_0Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.list"]>>
|
||||
export type FileListOperation<E = never> = (input?: Endpoint12_0Input) => Effect.Effect<Endpoint12_0Output, E>
|
||||
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
export type Endpoint12_1Input = {
|
||||
readonly location?: Endpoint12_1Request["query"]["location"]
|
||||
readonly query: Endpoint12_1Request["query"]["query"]
|
||||
readonly type?: Endpoint12_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint12_1Request["query"]["limit"]
|
||||
}
|
||||
export type Endpoint12_1Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.find"]>>
|
||||
export type FileFindOperation<E = never> = (input: Endpoint12_1Input) => Effect.Effect<Endpoint12_1Output, E>
|
||||
|
||||
export interface FileApi<E = never> {
|
||||
readonly list: FileListOperation<E>
|
||||
readonly find: FileFindOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
|
||||
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.command"]["command.list"]>>
|
||||
export type CommandListOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
|
||||
|
||||
export interface CommandApi<E = never> {
|
||||
readonly list: CommandListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.skill"]["skill.list"]>>
|
||||
export type SkillListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
||||
|
||||
export interface SkillApi<E = never> {
|
||||
readonly list: SkillListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint15_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
|
||||
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint15_0Output, E>
|
||||
|
||||
export interface EventApi<E = never> {
|
||||
readonly subscribe: EventSubscribeOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
export type Endpoint16_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
|
||||
export type PtyListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||
|
||||
type Endpoint16_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
export type Endpoint16_1Input = {
|
||||
readonly location?: Endpoint16_1Request["query"]["location"]
|
||||
readonly command?: Endpoint16_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint16_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint16_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint16_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint16_1Request["payload"]["env"]
|
||||
}
|
||||
export type Endpoint16_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
|
||||
export type PtyCreateOperation<E = never> = (input?: Endpoint16_1Input) => Effect.Effect<Endpoint16_1Output, E>
|
||||
|
||||
type Endpoint16_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
export type Endpoint16_2Input = {
|
||||
readonly ptyID: Endpoint16_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint16_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint16_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
|
||||
export type PtyGetOperation<E = never> = (input: Endpoint16_2Input) => Effect.Effect<Endpoint16_2Output, E>
|
||||
|
||||
type Endpoint16_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
export type Endpoint16_3Input = {
|
||||
readonly ptyID: Endpoint16_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint16_3Request["query"]["location"]
|
||||
readonly title?: Endpoint16_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint16_3Request["payload"]["size"]
|
||||
}
|
||||
export type Endpoint16_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
|
||||
export type PtyUpdateOperation<E = never> = (input: Endpoint16_3Input) => Effect.Effect<Endpoint16_3Output, E>
|
||||
|
||||
type Endpoint16_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
export type Endpoint16_4Input = {
|
||||
readonly ptyID: Endpoint16_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint16_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint16_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
|
||||
export type PtyRemoveOperation<E = never> = (input: Endpoint16_4Input) => Effect.Effect<Endpoint16_4Output, E>
|
||||
|
||||
export interface PtyApi<E = never> {
|
||||
readonly list: PtyListOperation<E>
|
||||
readonly create: PtyCreateOperation<E>
|
||||
readonly get: PtyGetOperation<E>
|
||||
readonly update: PtyUpdateOperation<E>
|
||||
readonly remove: PtyRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint17_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
|
||||
export type Endpoint17_0Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.list"]>>
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
||||
|
||||
type Endpoint17_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
export type Endpoint17_1Input = {
|
||||
readonly location?: Endpoint17_1Request["query"]["location"]
|
||||
readonly command: Endpoint17_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint17_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint17_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint17_1Request["payload"]["metadata"]
|
||||
}
|
||||
export type Endpoint17_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
|
||||
export type ShellCreateOperation<E = never> = (input: Endpoint17_1Input) => Effect.Effect<Endpoint17_1Output, E>
|
||||
|
||||
type Endpoint17_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
export type Endpoint17_2Input = {
|
||||
readonly id: Endpoint17_2Request["params"]["id"]
|
||||
readonly location?: Endpoint17_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint17_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint17_2Input) => Effect.Effect<Endpoint17_2Output, E>
|
||||
|
||||
type Endpoint17_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
export type Endpoint17_3Input = {
|
||||
readonly id: Endpoint17_3Request["params"]["id"]
|
||||
readonly location?: Endpoint17_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint17_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint17_3Request["query"]["limit"]
|
||||
}
|
||||
export type Endpoint17_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint17_3Input) => Effect.Effect<Endpoint17_3Output, E>
|
||||
|
||||
type Endpoint17_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
export type Endpoint17_4Input = {
|
||||
readonly id: Endpoint17_4Request["params"]["id"]
|
||||
readonly location?: Endpoint17_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint17_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint17_4Input) => Effect.Effect<Endpoint17_4Output, E>
|
||||
|
||||
export interface ShellApi<E = never> {
|
||||
readonly list: ShellListOperation<E>
|
||||
readonly create: ShellCreateOperation<E>
|
||||
readonly get: ShellGetOperation<E>
|
||||
readonly output: ShellOutputOperation<E>
|
||||
readonly remove: ShellRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||
export type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||
export type Endpoint18_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
|
||||
export type QuestionListRequestsOperation<E = never> = (
|
||||
input?: Endpoint18_0Input,
|
||||
) => Effect.Effect<Endpoint18_0Output, E>
|
||||
|
||||
type Endpoint18_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||
export type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] }
|
||||
export type Endpoint18_1Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.list"]>>["data"]
|
||||
export type QuestionListOperation<E = never> = (input: Endpoint18_1Input) => Effect.Effect<Endpoint18_1Output, E>
|
||||
|
||||
type Endpoint18_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||
export type Endpoint18_2Input = {
|
||||
readonly sessionID: Endpoint18_2Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint18_2Request["params"]["requestID"]
|
||||
readonly answers: Endpoint18_2Request["payload"]["answers"]
|
||||
}
|
||||
export type Endpoint18_2Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reply"]>>
|
||||
export type QuestionReplyOperation<E = never> = (input: Endpoint18_2Input) => Effect.Effect<Endpoint18_2Output, E>
|
||||
|
||||
type Endpoint18_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||
export type Endpoint18_3Input = {
|
||||
readonly sessionID: Endpoint18_3Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint18_3Request["params"]["requestID"]
|
||||
}
|
||||
export type Endpoint18_3Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reject"]>>
|
||||
export type QuestionRejectOperation<E = never> = (input: Endpoint18_3Input) => Effect.Effect<Endpoint18_3Output, E>
|
||||
|
||||
export interface QuestionApi<E = never> {
|
||||
readonly listRequests: QuestionListRequestsOperation<E>
|
||||
readonly list: QuestionListOperation<E>
|
||||
readonly reply: QuestionReplyOperation<E>
|
||||
readonly reject: QuestionRejectOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||
export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
export type Endpoint19_0Output = EffectValue<ReturnType<RawClient["server.reference"]["reference.list"]>>
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint19_0Input) => Effect.Effect<Endpoint19_0Output, E>
|
||||
|
||||
export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
|
||||
export type Endpoint20_0Input = {
|
||||
readonly projectID: Endpoint20_0Request["params"]["projectID"]
|
||||
readonly location?: Endpoint20_0Request["query"]["location"]
|
||||
readonly strategy: Endpoint20_0Request["payload"]["strategy"]
|
||||
readonly directory: Endpoint20_0Request["payload"]["directory"]
|
||||
readonly name?: Endpoint20_0Request["payload"]["name"]
|
||||
}
|
||||
export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.projectCopy"]["projectCopy.create"]>>
|
||||
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
||||
|
||||
type Endpoint20_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
|
||||
export type Endpoint20_1Input = {
|
||||
readonly projectID: Endpoint20_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint20_1Request["query"]["location"]
|
||||
readonly directory: Endpoint20_1Request["payload"]["directory"]
|
||||
readonly force: Endpoint20_1Request["payload"]["force"]
|
||||
}
|
||||
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.projectCopy"]["projectCopy.remove"]>>
|
||||
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
|
||||
|
||||
type Endpoint20_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
|
||||
export type Endpoint20_2Input = {
|
||||
readonly projectID: Endpoint20_2Request["params"]["projectID"]
|
||||
readonly location?: Endpoint20_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.projectCopy"]["projectCopy.refresh"]>>
|
||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||
|
||||
export interface ProjectCopyApi<E = never> {
|
||||
readonly create: ProjectCopyCreateOperation<E>
|
||||
readonly remove: ProjectCopyRemoveOperation<E>
|
||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
readonly agent: AgentApi<E>
|
||||
readonly session: SessionApi<E>
|
||||
readonly message: MessageApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
readonly generate: GenerateApi<E>
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly integration: IntegrationApi<E>
|
||||
readonly credential: CredentialApi<E>
|
||||
readonly project: ProjectApi<E>
|
||||
readonly permission: PermissionApi<E>
|
||||
readonly file: FileApi<E>
|
||||
readonly command: CommandApi<E>
|
||||
readonly skill: SkillApi<E>
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
export type { PluginContext } from "./context.js"
|
||||
export { define } from "./plugin.js"
|
||||
export type { Plugin } from "./plugin.js"
|
||||
export type { ToolDomain } from "./tool.js"
|
||||
export type { SessionDomain } from "./runtime.js"
|
||||
|
|
|
|||
3
packages/plugin/src/v2/effect/runtime.ts
Normal file
3
packages/plugin/src/v2/effect/runtime.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { SessionApi } from "./generated/api.js"
|
||||
|
||||
export type SessionDomain = Pick<SessionApi<unknown>, "create" | "get" | "prompt" | "interrupt">
|
||||
5
packages/plugin/src/v2/effect/tool.ts
Normal file
5
packages/plugin/src/v2/effect/tool.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Effect, Scope } from "effect"
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly register: (tools: Readonly<Record<string, unknown>>) => Effect.Effect<void, unknown, Scope.Scope>
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
|
|
|
|||
60
packages/protocol/src/client.ts
Normal file
60
packages/protocol/src/client.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { InvalidRequestError, SessionNotFoundError } from "./errors"
|
||||
import { makeDefaultApi } from "./api"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||
"@opencode-ai/client/LocationMiddleware",
|
||||
) {}
|
||||
|
||||
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
|
||||
"@opencode-ai/client/SessionLocationMiddleware",
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
|
||||
export const ClientApi = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.location": "location",
|
||||
"server.agent": "agent",
|
||||
"server.session": "session",
|
||||
"server.message": "message",
|
||||
"server.model": "model",
|
||||
"server.generate": "generate",
|
||||
"server.provider": "provider",
|
||||
"server.integration": "integration",
|
||||
"server.credential": "credential",
|
||||
"server.permission": "permission",
|
||||
"server.fs": "file",
|
||||
"server.command": "command",
|
||||
"server.skill": "skill",
|
||||
"server.event": "event",
|
||||
"server.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
} as const
|
||||
|
||||
export const endpointNames = {
|
||||
"session.messages": "list",
|
||||
"integration.connect.key": "connectKey",
|
||||
"integration.connect.oauth": "connectOauth",
|
||||
"integration.attempt.status": "attemptStatus",
|
||||
"integration.attempt.complete": "attemptComplete",
|
||||
"integration.attempt.cancel": "attemptCancel",
|
||||
"session.revert.stage": "revertStage",
|
||||
"session.revert.clear": "revertClear",
|
||||
"session.revert.commit": "revertCommit",
|
||||
"permission.request.list": "listRequests",
|
||||
"permission.saved.list": "listSaved",
|
||||
"permission.saved.remove": "removeSaved",
|
||||
"question.request.list": "listRequests",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
export * as Mcp from "./mcp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema"
|
||||
import { IntegrationID } from "./integration-id"
|
||||
|
||||
const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
|
||||
identifier: "Mcp.Status.Connected",
|
||||
|
|
@ -36,4 +38,7 @@ export interface Server extends Schema.Schema.Type<typeof Server> {}
|
|||
export const Server = Schema.Struct({
|
||||
name: Schema.String,
|
||||
status: Status,
|
||||
// Set for remote servers registered as OAuth integrations; lets clients act on the right integration
|
||||
// without matching by name, which could collide with provider or plugin integrations.
|
||||
integrationID: optional(IntegrationID),
|
||||
}).annotate({ identifier: "Mcp.Server" })
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const opencode = yield * OpenCode.create()
|
|||
const session = yield * opencode.sessions.get({ sessionID })
|
||||
```
|
||||
|
||||
It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
It also exports `Tool` for plugins that register tools with `ctx.tool.register(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
|
||||
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
|
|
@ -9,18 +8,18 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
|
|||
export const create = Effect.fn("OpenCode.create")(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const sdkPlugins = SdkPlugins.makeStore()
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
Layer.mergeAll(ApplicationTools.layer, PermissionSaved.defaultLayer, SdkPlugins.layer),
|
||||
Layer.mergeAll(PermissionSaved.defaultLayer, SdkPlugins.layerWithStore(sdkPlugins)),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
const tools = Context.get(context, ApplicationTools.Service)
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const permissions = Context.get(context, PermissionSaved.Service)
|
||||
const web = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
createEmbeddedRoutes().pipe(
|
||||
createEmbeddedRoutes(sdkPlugins).pipe(
|
||||
HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
|
|
@ -38,7 +37,8 @@ export const create = Effect.fn("OpenCode.create")(function* () {
|
|||
)
|
||||
return {
|
||||
...client,
|
||||
tools: { register: tools.register },
|
||||
sessions: client.session,
|
||||
events: client.event,
|
||||
// The embedded host contributes plugins through the ordinary discovery flow:
|
||||
// each plugin's `effect` runs inside every Location with the real
|
||||
// `PluginContext`, so `ctx.agent.transform` and every other hook behave exactly
|
||||
|
|
|
|||
|
|
@ -1,212 +1,206 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect"
|
||||
import { Deferred, Effect, Latch, Layer, Option, Schema, Stream } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import type { OpenCodeEvent } from "../src"
|
||||
|
||||
test("embedded client uses the real router and handlers", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
|
||||
Flag.OPENCODE_DB = ":memory:"
|
||||
|
||||
try {
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
yield* opencode.tools.register({
|
||||
embedded_tool: Tool.make({
|
||||
description: "Embedded test tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
const it = testEffect(Layer.empty)
|
||||
type Sdk = typeof import("../src")
|
||||
type Fixture = { readonly directory: string; readonly sdk: Sdk }
|
||||
|
||||
const created = yield* opencode.session.create({
|
||||
id: sessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
yield* opencode.session.switchModel({ sessionID, model })
|
||||
const selected = yield* opencode.session.get({ sessionID })
|
||||
const page = yield* opencode.session.list({ directory: AbsolutePath.make(directory) })
|
||||
const active = yield* opencode.session.active()
|
||||
const admitted = yield* opencode.session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Do not run" }),
|
||||
resume: false,
|
||||
})
|
||||
const context = yield* opencode.session.context({ sessionID })
|
||||
const wake = yield* opencode.session.prompt({
|
||||
sessionID,
|
||||
prompt: Prompt.make({ text: "Promote this input" }),
|
||||
})
|
||||
const prompted = yield* opencode.session.events({ sessionID }).pipe(
|
||||
Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
|
||||
Stream.runHead,
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.map(Option.getOrThrow),
|
||||
)
|
||||
const wakeContext = yield* opencode.session.context({ sessionID })
|
||||
const event = yield* opencode.session
|
||||
.events({ sessionID })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
|
||||
const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
|
||||
Option.getOrThrow,
|
||||
)
|
||||
const message = yield* opencode.session.message({ sessionID, messageID: modelMessage.id })
|
||||
yield* opencode.session.interrupt({ sessionID })
|
||||
const other = yield* opencode.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`)
|
||||
const missing = yield* Effect.all(
|
||||
[
|
||||
opencode.session.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
|
||||
opencode.session.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||
opencode.session.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const missingMessage = yield* Effect.flip(
|
||||
opencode.session.message({
|
||||
sessionID: other.id,
|
||||
messageID: modelMessage.id,
|
||||
}),
|
||||
)
|
||||
const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(prefix)),
|
||||
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((directory) =>
|
||||
Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))),
|
||||
),
|
||||
)
|
||||
|
||||
expect(created.id).toBe(sessionID)
|
||||
expect(selected.model?.id).toBe(model.id)
|
||||
expect(selected.model?.providerID).toBe(model.providerID)
|
||||
expect(page.data.some((session) => session.id === sessionID)).toBe(true)
|
||||
expect(active).toEqual({})
|
||||
expect(admitted.sessionID).toBe(sessionID)
|
||||
expect(prompted.type).toBe("session.next.prompted")
|
||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||
expect(context.some((message) => message.type === "model-switched")).toBe(true)
|
||||
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
|
||||
expect(message).toEqual(modelMessage)
|
||||
expect(missing.map((error) => error._tag)).toEqual([
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
])
|
||||
expect(missingMessage._tag).toBe("MessageNotFoundError")
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
|
||||
|
||||
test("Location-owned runner events reach the ready global client", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
const location = (fixture: Fixture) =>
|
||||
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
|
||||
|
||||
try {
|
||||
const program = Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.create()
|
||||
const connected = yield* Latch.make(false)
|
||||
const prompted = yield* Deferred.make<OpenCodeEvent>()
|
||||
yield* opencode.event.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
event.type === "server.connected"
|
||||
? connected.open
|
||||
: event.type === "session.next.prompted" && event.data.sessionID === sessionID
|
||||
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* connected.await
|
||||
yield* opencode.session.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
yield* opencode.session.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) })
|
||||
it.live(
|
||||
"embedded client uses the real router and handlers",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const id = sessionID(fixture)
|
||||
const model = fixture.sdk.Model.Ref.make({
|
||||
id: fixture.sdk.Model.ID.make("embedded"),
|
||||
providerID: fixture.sdk.Provider.ID.make("test"),
|
||||
})
|
||||
|
||||
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||
expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) }))
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 10_000)
|
||||
yield* opencode.plugin({
|
||||
id: `embedded-tools-${crypto.randomUUID()}`,
|
||||
effect: (ctx) =>
|
||||
ctx.tool
|
||||
.register({
|
||||
embedded_tool: fixture.sdk.Tool.make({
|
||||
description: "Embedded test tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
test("independent embedded hosts do not share live notifications", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
|
||||
try {
|
||||
const program = Effect.gen(function* () {
|
||||
const first = yield* OpenCode.create()
|
||||
const second = yield* OpenCode.create()
|
||||
const firstReady = yield* Latch.make(false)
|
||||
const secondReady = yield* Latch.make(false)
|
||||
const firstEvent = yield* Latch.make(false)
|
||||
const secondEvent = yield* Latch.make(false)
|
||||
const observe = (ready: Latch.Latch, event: Latch.Latch) =>
|
||||
Stream.runForEach((notification: OpenCodeEvent) =>
|
||||
notification.type === "server.connected"
|
||||
? ready.open
|
||||
: notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID
|
||||
? event.open
|
||||
: Effect.void,
|
||||
const created = yield* opencode.sessions.create({
|
||||
id,
|
||||
agent: fixture.sdk.Agent.ID.make("build"),
|
||||
location: location(fixture),
|
||||
})
|
||||
yield* opencode.sessions.switchModel({ sessionID: id, model })
|
||||
const selected = yield* opencode.sessions.get({ sessionID: id })
|
||||
const page = yield* opencode.sessions.list({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
|
||||
const active = yield* opencode.sessions.active()
|
||||
const admitted = yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Do not run" }),
|
||||
resume: false,
|
||||
})
|
||||
const context = yield* opencode.sessions.context({ sessionID: id })
|
||||
const wake = yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
|
||||
})
|
||||
const prompted = yield* opencode.sessions.events({ sessionID: id }).pipe(
|
||||
Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id),
|
||||
Stream.runHead,
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.map(Option.getOrThrow),
|
||||
)
|
||||
const wakeContext = yield* opencode.sessions.context({ sessionID: id })
|
||||
const event = yield* opencode.sessions
|
||||
.events({ sessionID: id })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined))
|
||||
const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
|
||||
Option.getOrThrow,
|
||||
)
|
||||
const message = yield* opencode.sessions.message({ sessionID: id, messageID: modelMessage.id })
|
||||
yield* opencode.sessions.interrupt({ sessionID: id })
|
||||
const other = yield* opencode.sessions.create({ location: location(fixture) })
|
||||
const missingSessionID = fixture.sdk.Session.ID.create()
|
||||
const missing = yield* Effect.all(
|
||||
[
|
||||
opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
|
||||
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
|
||||
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const missingMessage = yield* Effect.flip(
|
||||
opencode.sessions.message({
|
||||
sessionID: other.id,
|
||||
messageID: modelMessage.id,
|
||||
}),
|
||||
)
|
||||
|
||||
yield* first.event.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
|
||||
yield* second.event.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
|
||||
yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
|
||||
yield* first.session.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
yield* first.session.switchAgent({ sessionID, agent: Agent.ID.make("plan") })
|
||||
expect(created.id).toBe(id)
|
||||
expect(selected.model?.id).toBe(model.id)
|
||||
expect(selected.model?.providerID).toBe(model.providerID)
|
||||
expect(page.data.some((session) => session.id === id)).toBe(true)
|
||||
expect(active).toEqual({})
|
||||
expect(admitted.sessionID).toBe(id)
|
||||
expect(prompted.type).toBe("session.next.prompted")
|
||||
expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
|
||||
expect(context.some((message) => message.type === "model-switched")).toBe(true)
|
||||
expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } })
|
||||
expect(message).toEqual(modelMessage)
|
||||
expect(missing.map((error) => error._tag)).toEqual([
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
])
|
||||
expect(missingMessage._tag).toBe("MessageNotFoundError")
|
||||
}),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
|
||||
yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
|
||||
expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(program))
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
test("embedded client is available as a Layer service", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
|
||||
const database = Flag.OPENCODE_DB
|
||||
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
|
||||
const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
|
||||
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
|
||||
|
||||
try {
|
||||
const created = await Effect.runPromise(
|
||||
it.live(
|
||||
"Location-owned runner events reach the ready global client",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-events-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
return yield* opencode.session.create({
|
||||
id: sessionID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
|
||||
})
|
||||
}).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
|
||||
)
|
||||
const opencode = yield* fixture.sdk.OpenCode.create()
|
||||
const id = sessionID(fixture)
|
||||
const connected = yield* Latch.make(false)
|
||||
const prompted = yield* Deferred.make<OpenCodeEvent>()
|
||||
|
||||
expect(created.id).toBe(sessionID)
|
||||
} finally {
|
||||
Flag.OPENCODE_DB = database
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
yield* opencode.events.subscribe().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
event.type === "server.connected"
|
||||
? connected.open
|
||||
: event.type === "session.next.prompted" && event.data.sessionID === id
|
||||
? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* connected.await
|
||||
yield* opencode.sessions.create({ id, location: location(fixture) })
|
||||
yield* opencode.sessions.prompt({
|
||||
sessionID: id,
|
||||
prompt: fixture.sdk.Prompt.make({ text: "Observe this input" }),
|
||||
})
|
||||
|
||||
const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
|
||||
expect(event.durable).toEqual(expect.objectContaining({ aggregateID: id, seq: expect.any(Number) }))
|
||||
}),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"independent embedded hosts do not share live notifications",
|
||||
() =>
|
||||
withEmbedded("opencode-embedded-hosts-", (fixture) =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* fixture.sdk.OpenCode.create()
|
||||
const second = yield* fixture.sdk.OpenCode.create()
|
||||
const id = sessionID(fixture)
|
||||
const firstReady = yield* Latch.make(false)
|
||||
const secondReady = yield* Latch.make(false)
|
||||
const firstEvent = yield* Latch.make(false)
|
||||
const secondEvent = yield* Latch.make(false)
|
||||
const observe = (ready: Latch.Latch, event: Latch.Latch) =>
|
||||
Stream.runForEach((notification: OpenCodeEvent) =>
|
||||
notification.type === "server.connected"
|
||||
? ready.open
|
||||
: notification.type === "session.next.agent.switched" && notification.data.sessionID === id
|
||||
? event.open
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
|
||||
yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
|
||||
yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
|
||||
yield* first.sessions.create({ id, location: location(fixture) })
|
||||
yield* first.sessions.switchAgent({ sessionID: id, agent: fixture.sdk.Agent.ID.make("plan") })
|
||||
|
||||
yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
|
||||
expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
|
||||
}),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live("embedded client is available as a Layer service", () =>
|
||||
withEmbedded("opencode-embedded-layer-", (fixture) => {
|
||||
const id = sessionID(fixture)
|
||||
return Effect.gen(function* () {
|
||||
const opencode = yield* fixture.sdk.OpenCode.Service
|
||||
const created = yield* opencode.sessions.create({ id, location: location(fixture) })
|
||||
expect(created.id).toBe(id)
|
||||
}).pipe(Effect.provide(fixture.sdk.OpenCode.layer))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5298,6 +5298,7 @@ export type McpServer = {
|
|||
| McpStatusFailed2
|
||||
| McpStatusNeedsAuth2
|
||||
| McpStatusNeedsClientRegistration2
|
||||
integrationID?: string
|
||||
}
|
||||
|
||||
export type ProjectCurrent = {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { response } from "../location"
|
|||
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
|
||||
handlers.handle("agent.list", () =>
|
||||
Effect.gen(function* () {
|
||||
return yield* response(AgentV2.Service.use((agent) => agent.all()))
|
||||
return yield* response(AgentV2.Service.use((agent) => agent.list()))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
|||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
return yield* response(
|
||||
service.servers().pipe(Effect.map((servers) => servers.map((info) => ({ name: info.name, status: info.status })))),
|
||||
service
|
||||
.servers()
|
||||
.pipe(
|
||||
Effect.map((servers) =>
|
||||
servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
|
|
@ -31,8 +31,7 @@ const applicationServices = LayerNode.group([
|
|||
httpClient,
|
||||
ToolOutputStore.cleanupNode,
|
||||
SessionV2.node,
|
||||
SubagentTool.node,
|
||||
ShellTool.node,
|
||||
PluginRuntime.providerNode,
|
||||
PermissionSaved.node,
|
||||
PtyTicket.node,
|
||||
Credential.node,
|
||||
|
|
@ -48,13 +47,22 @@ export function createRoutes(password?: string) {
|
|||
)
|
||||
}
|
||||
|
||||
export function createEmbeddedRoutes() {
|
||||
return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() }))
|
||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
||||
return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
sdkPlugins?: SdkPlugins.Store,
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const serviceLayer = AppNodeBuilder.build(
|
||||
LayerNode.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node),
|
||||
[
|
||||
LayerNode.replace(PluginRuntime.layer, PluginRuntime.layerWithCell(pluginRuntimeCell)),
|
||||
LayerNode.replace(PluginRuntime.providerLayer, PluginRuntime.providerLayerWithCell(pluginRuntimeCell)),
|
||||
...(sdkPlugins ? [LayerNode.replace(SdkPlugins.layer, SdkPlugins.layerWithStore(sdkPlugins))] : []),
|
||||
],
|
||||
)
|
||||
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
|
|
|
|||
|
|
@ -1143,7 +1143,7 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await sdk.api.session.commit({ sessionID }).then(
|
||||
const error = await sdk.api.session.revertCommit({ sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
|
|||
description: "undo messages and file changes",
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.api.session
|
||||
.stage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.revertStage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
dialog.clear()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -185,7 +185,9 @@ export function Session() {
|
|||
})
|
||||
const permissions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return [route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? [])
|
||||
return [route.sessionID, ...descendantSessionIDs()].flatMap(
|
||||
(sessionID) => data.session.permission.list(sessionID) ?? [],
|
||||
)
|
||||
})
|
||||
const questions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
|
|
@ -422,7 +424,7 @@ export function Session() {
|
|||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const error = await sdk.api.session.stage({ sessionID: route.sessionID, messageID: target }).then(
|
||||
const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -439,7 +441,7 @@ export function Session() {
|
|||
slash: { name: "redo" },
|
||||
run: () => {
|
||||
void (async () => {
|
||||
const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then(
|
||||
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -1049,7 +1051,9 @@ function SessionMessageView(props: { message: SessionMessage }) {
|
|||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}>
|
||||
<Match
|
||||
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
||||
>
|
||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} />
|
||||
</Show>
|
||||
|
|
@ -1217,11 +1221,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
|
|||
if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
<text fg={theme.textMuted}>
|
||||
{text()}
|
||||
</text>
|
||||
)
|
||||
return <text fg={theme.textMuted}>{text()}</text>
|
||||
}
|
||||
|
||||
function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) {
|
||||
|
|
@ -1267,7 +1267,7 @@ function RevertMessage(props: {
|
|||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
void (async () => {
|
||||
const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then(
|
||||
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ This document breaks the legacy configuration schema into small review groups. W
|
|||
|
||||
Use one v2 config schema for now. Some fields, such as `autoupdate`, are intended for global/user configuration, but there is not yet enough benefit to enforce that with separate global and location schemas. Revisit this if more scope-sensitive fields survive the review.
|
||||
|
||||
V2 core discovers config documents named `opencode.json` or `opencode.jsonc` in the global config directory, ancestor project directories, and `.opencode` config directories. The legacy `config.json` filename is not supported in V2.
|
||||
|
||||
## Group 1: File Metadata
|
||||
|
||||
Small fields describing the config file itself rather than application behavior.
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ Event replay owner claims are separate from clustered Session execution ownershi
|
|||
|
||||
## Current Tool Registry Slice
|
||||
|
||||
`ApplicationTools` stores process-scoped application registrations shared by all Locations. Each Location-scoped `ToolRegistry` overlays Location registrations, materializes definitions, and owns lookup and settlement. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors.
|
||||
Each Location-scoped `ToolRegistry` stores scoped tool registrations, materializes definitions, and owns lookup and settlement. Built-ins and plugins contribute through the same `Tools.Service.register(...)` path. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors.
|
||||
|
||||
When a Session omits `agent`, both execution and permission evaluation use the default `build` agent. A caller must not observe `build` model behavior while permission checks silently evaluate an empty no-agent policy.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue