From 4e75d3b33e0b8c02e17006e930d68ecfb9f51240 Mon Sep 17 00:00:00 2001 From: "will.anderson" Date: Sat, 22 Aug 2026 17:42:51 -0500 Subject: [PATCH] refactor(opencode): remove instruction apparatus; add wire dialects from scratch - delete instruction.ts, reminders.ts, system.ts, message.ts (dead or injector) - read tool no longer appends context from ancestor files - prompt.ts assembles system context via SessionContext with provenance tags - llm/request.ts uses base prompt constant directly - provider/dialects/: raw-HTTP wire dialects (anthropic, openai, openai-compatible) + registry - no vendor SDK participation - provider.ts: BUNDLED_PROVIDERS cut to three SDKs pending dialect cutover --- packages/opencode/src/effect/app-runtime.ts | 2 - .../src/provider/dialects/anthropic.ts | 82 +++++++ .../provider/dialects/openai-compatible.ts | 69 ++++++ .../opencode/src/provider/dialects/openai.ts | 67 ++++++ .../src/provider/dialects/registry.ts | 22 ++ .../opencode/src/provider/dialects/types.ts | 41 ++++ packages/opencode/src/provider/provider.ts | 18 +- .../server/routes/instance/httpapi/server.ts | 2 - packages/opencode/src/session/instruction.ts | 219 ------------------ packages/opencode/src/session/llm/request.ts | 4 +- packages/opencode/src/session/message.ts | 148 ------------ packages/opencode/src/session/prompt.ts | 33 +-- packages/opencode/src/session/reminders.ts | 62 ----- packages/opencode/src/session/system.ts | 123 ---------- packages/opencode/src/tool/read.ts | 12 +- packages/opencode/src/tool/registry.ts | 2 - packages/opencode/test/config/config.test.ts | 25 -- packages/opencode/test/config/tui.test.ts | 136 +---------- .../test/fixture/plugin-meta-worker.ts | 19 -- 19 files changed, 295 insertions(+), 791 deletions(-) create mode 100644 packages/opencode/src/provider/dialects/anthropic.ts create mode 100644 packages/opencode/src/provider/dialects/openai-compatible.ts create mode 100644 packages/opencode/src/provider/dialects/openai.ts create mode 100644 packages/opencode/src/provider/dialects/registry.ts create mode 100644 packages/opencode/src/provider/dialects/types.ts delete mode 100644 packages/opencode/src/session/instruction.ts delete mode 100644 packages/opencode/src/session/message.ts delete mode 100644 packages/opencode/src/session/reminders.ts delete mode 100644 packages/opencode/src/session/system.ts delete mode 100644 packages/opencode/test/fixture/plugin-meta-worker.ts diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index a116bcb950..3970ccf81e 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -27,7 +27,6 @@ import { SessionCompaction } from "@/session/compaction" import { SessionRevert } from "@/session/revert" import { SessionSummary } from "@/session/summary" import { SessionPrompt } from "@/session/prompt" -import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" @@ -84,7 +83,6 @@ export const AppLayer = AppNodeBuilderV1.build( SessionRevert.node, SessionSummary.node, SessionPrompt.node, - Instruction.node, LLM.node, LSP.node, MCP.node, diff --git a/packages/opencode/src/provider/dialects/anthropic.ts b/packages/opencode/src/provider/dialects/anthropic.ts new file mode 100644 index 0000000000..acf182522c --- /dev/null +++ b/packages/opencode/src/provider/dialects/anthropic.ts @@ -0,0 +1,82 @@ +export * as AnthropicDialect from "./anthropic" + +import type { Dialect, Event, Request } from "./types" + +/** + * Anthropic wire protocol: POST /v1/messages, x-api-key auth, + * server-sent events with typed content blocks. + */ + +export const api = "@ai-sdk/anthropic" + +export const options = { + headers: { + "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14", + }, +} + +export function create(input: { + baseURL: string + apiKey: string + model: string + system: string[] + messages: unknown[] + tools?: unknown[] +}): Request { + return { + url: `${input.baseURL}/v1/messages`, + headers: { + "content-type": "application/json", + "x-api-key": input.apiKey, + "anthropic-version": "2023-06-01", + ...options.headers, + }, + body: { + model: input.model, + max_tokens: 8192, + stream: true, + ...(input.system.length > 0 ? { system: input.system } : {}), + messages: input.messages, + ...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}), + }, + } +} + +type Block = + | { type: "text"; text?: string } + | { type: "thinking"; thinking?: string } + | { type: "tool_use"; id?: string; name?: string; input?: unknown } + | { type: "input_json_delta"; partial_json?: string } + +export function parse(data: string): Event[] { + let json: any + try { + json = JSON.parse(data) + } catch { + return [] + } + switch (json.type) { + case "content_block_delta": { + const delta = json.delta as Block | undefined + if (delta?.type === "text" && delta.text) return [{ type: "text-delta", text: delta.text }] + if (delta?.type === "thinking" && delta.thinking) + return [{ type: "reasoning-delta", text: delta.thinking }] + if (delta?.type === "input_json_delta" && typeof json.delta.partial_json === "string") + return [{ type: "tool-call", id: String(json.index), name: "", arguments: json.delta.partial_json }] + return [] + } + case "message_delta": { + const reason = json.delta?.stop_reason + if (reason) return [{ type: "finish", reason: String(reason) }] + return [] + } + case "message_stop": + return [{ type: "finish", reason: "stop" }] + case "error": + return [{ type: "error", message: String(json.error?.message ?? "unknown anthropic error") }] + default: + return [] + } +} + +export const dialect: Dialect = { request: create, parse } diff --git a/packages/opencode/src/provider/dialects/openai-compatible.ts b/packages/opencode/src/provider/dialects/openai-compatible.ts new file mode 100644 index 0000000000..9543b7808d --- /dev/null +++ b/packages/opencode/src/provider/dialects/openai-compatible.ts @@ -0,0 +1,69 @@ +export * as OpenAICompatibleDialect from "./openai-compatible" + +import type { Dialect, Event, Request } from "./types" + +/** + * The generic dialect. Nearly every inference vendor on earth - Groq, + * Mistral, DeepInfra, Together, Perplexity, OpenRouter, local servers - + * speaks the OpenAI chat-completions wire shape at a different baseURL. + * + * A vendor is therefore DATA: one catalog row (name, baseURL, auth). + * No vendor-specific code exists or may be added here. + */ + +export function create(input: { + baseURL: string + apiKey: string + model: string + system: string[] + messages: unknown[] + tools?: unknown[] +}): Request { + return { + url: `${input.baseURL}/v1/chat/completions`, + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.apiKey}`, + }, + body: { + model: input.model, + stream: true, + ...(input.system.length > 0 + ? { messages: [{ role: "system", content: input.system.join("\n\n") }, ...input.messages] } + : { messages: input.messages }), + ...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}), + }, + } +} + +export function parse(data: string): Event[] { + let json: any + try { + json = JSON.parse(data) + } catch { + return [] + } + const choice = json.choices?.[0] + if (!choice) { + if (json.error) return [{ type: "error", message: String(json.error.message ?? "unknown error") }] + return [] + } + const events: Event[] = [] + const delta = choice.delta ?? {} + if (typeof delta.content === "string" && delta.content.length > 0) + events.push({ type: "text-delta", text: delta.content }) + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) + events.push({ type: "reasoning-delta", text: delta.reasoning_content }) + for (const call of delta.tool_calls ?? []) { + events.push({ + type: "tool-call", + id: String(call.id ?? call.index ?? ""), + name: String(call.function?.name ?? ""), + arguments: String(call.function?.arguments ?? ""), + }) + } + if (choice.finish_reason) events.push({ type: "finish", reason: String(choice.finish_reason) }) + return events +} + +export const dialect: Dialect = { request: create, parse } diff --git a/packages/opencode/src/provider/dialects/openai.ts b/packages/opencode/src/provider/dialects/openai.ts new file mode 100644 index 0000000000..4261958baf --- /dev/null +++ b/packages/opencode/src/provider/dialects/openai.ts @@ -0,0 +1,67 @@ +export * as OpenAIDialect from "./openai" + +import type { Dialect, Event, Request } from "./types" + +/** + * OpenAI wire protocol: POST /v1/responses (fallback /v1/chat/completions), + * Bearer auth, server-sent events. Also the reference shape for every + * OpenAI-compatible vendor - only the baseURL differs. + */ + +export const api = "@ai-sdk/openai" + +export const options = {} + +export function create(input: { + baseURL: string + apiKey: string + model: string + system: string[] + messages: unknown[] + tools?: unknown[] +}): Request { + return { + url: `${input.baseURL}/v1/responses`, + headers: { + "content-type": "application/json", + authorization: `Bearer ${input.apiKey}`, + }, + body: { + model: input.model, + stream: true, + ...(input.system.length > 0 ? { instructions: input.system.join("\n\n") } : {}), + input: input.messages, + ...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}), + }, + } +} + +export function parse(data: string): Event[] { + let json: any + try { + json = JSON.parse(data) + } catch { + return [] + } + switch (json.type) { + case "response.output_text.delta": + return [{ type: "text-delta", text: String(json.delta ?? "") }] + case "response.reasoning_summary_text.delta": + return [{ type: "reasoning-delta", text: String(json.delta ?? "") }] + case "response.function_call_arguments.delta": + return [ + { type: "tool-call", id: String(json.item_id ?? ""), name: String(json.name ?? ""), arguments: String(json.delta ?? "") }, + ] + case "response.completed": + case "response.failed": { + const reason = json.response?.status === "failed" ? "error" : "stop" + return [{ type: "finish", reason }] + } + case "error": + return [{ type: "error", message: String(json.message ?? "unknown openai error") }] + default: + return [] + } +} + +export const dialect: Dialect = { request: create, parse } diff --git a/packages/opencode/src/provider/dialects/registry.ts b/packages/opencode/src/provider/dialects/registry.ts new file mode 100644 index 0000000000..511571da60 --- /dev/null +++ b/packages/opencode/src/provider/dialects/registry.ts @@ -0,0 +1,22 @@ +export * as Registry from "./registry" + +import type { Dialect } from "./types" +import { dialect as anthropic, api as anthropicApi } from "./anthropic" +import { dialect as openai, api as openaiApi } from "./openai" +import { dialect as openaiCompatible } from "./openai-compatible" + +/** + * Wire-dialect registry. Keyed by the catalog's npm/api identifier so any + * vendor row resolves to the protocol it speaks. Vendors without an entry + * fall back to the OpenAI-compatible dialect - the wire shape nearly every + * inference vendor shares. + */ + +const DIALECTS: Record = { + [anthropicApi]: anthropic, + [openaiApi]: openai, +} + +export function forAPI(npm: string): Dialect { + return DIALECTS[npm] ?? openaiCompatible +} diff --git a/packages/opencode/src/provider/dialects/types.ts b/packages/opencode/src/provider/dialects/types.ts new file mode 100644 index 0000000000..f31c806e11 --- /dev/null +++ b/packages/opencode/src/provider/dialects/types.ts @@ -0,0 +1,41 @@ +export * as Dialect from "./types" + +/** + * A wire dialect: how Neuron speaks to one family of inference APIs. + * + * A dialect is pure HTTP - build a request, translate server-sent events + * into Neuron's normalized event stream. No vendor SDK participates. + * Every byte on the wire is visible in this folder. + */ + +export interface Request { + url: string + headers: Record + body: unknown +} + +export type Event = + | { type: "text-delta"; text: string } + | { type: "reasoning-delta"; text: string } + | { + type: "tool-call" + id: string + name: string + arguments: string + } + | { type: "finish"; reason: string } + | { type: "error"; message: string } + +export interface Dialect { + /** Build the streaming completion request. */ + request(input: { + baseURL: string + apiKey: string + model: string + system: string[] + messages: unknown[] + tools?: unknown[] + }): Request + /** Translate one server-sent-event data payload into normalized events. */ + parse(data: string): Event[] +} diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cbab6105bd..0c7c5b4ea9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -112,24 +112,10 @@ type BundledSDK = { const BUNDLED_PROVIDERS: Record Promise<(opts: any) => BundledSDK>> = { "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic), - "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure), - "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI), "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI), + // Every other vendor in the catalog is OpenAI-shaped: same wire protocol, + // different baseURL. One generic strategy covers them all. "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible), - "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter), - "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai), - "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral), - "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq), - "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra), - "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras), - "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere), - "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway), - "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI), - "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity), - "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel), - "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba), - "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab), - "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice), } type CustomModelLoader = (sdk: any, modelID: string, options?: Record, model?: Model) => Promise diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a6293a36ed..751d746294 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -28,7 +28,6 @@ import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" import { Question } from "@/question" import { SessionCompaction } from "@/session/compaction" -import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" import { SessionProcessor } from "@/session/processor" import { SessionPrompt } from "@/session/prompt" @@ -238,7 +237,6 @@ const app = LayerNode.group([ SessionRevert.node, SessionSummary.node, SessionPrompt.node, - Instruction.node, LLM.node, LSP.node, MCP.node, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts deleted file mode 100644 index 5fa69ae87f..0000000000 --- a/packages/opencode/src/session/instruction.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import path from "path" -import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Effect, Layer, Context } from "effect" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { Flag } from "@opencode-ai/core/flag/flag" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Global } from "@opencode-ai/core/global" -import type { MessageV2 } from "./message-v2" -import type { MessageID } from "./schema" - -function extract(messages: SessionV1.WithParts[]) { - const paths = new Set() - for (const msg of messages) { - for (const part of msg.parts) { - if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") { - if (part.state.time.compacted) continue - const loaded = part.state.metadata?.loaded - if (!loaded || !Array.isArray(loaded)) continue - for (const p of loaded) { - if (typeof p === "string") paths.add(p) - } - } - } - } - return paths -} - -export interface Interface { - readonly clear: (messageID: MessageID) => Effect.Effect - readonly systemPaths: () => Effect.Effect, FSUtil.Error> - readonly system: () => Effect.Effect - readonly find: (dir: string) => Effect.Effect - readonly resolve: ( - messages: SessionV1.WithParts[], - filepath: string, - messageID: MessageID, - ) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error> -} - -export class Service extends Context.Service()("@opencode/Instruction") {} - -const layer: Layer.Layer< - Service, - never, - FSUtil.Service | Config.Service | Global.Service | RuntimeFlags.Service -> = Layer.effect( - Service, - Effect.gen(function* () { - const cfg = yield* Config.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const flags = yield* RuntimeFlags.Service - const globalFiles = [ - path.join(global.config, "AGENTS.md"), - ...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []), - ] - const instructionFiles = [ - "AGENTS.md", - ...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []), - "CONTEXT.md", // deprecated - ] - - const state = yield* InstanceState.make( - Effect.fn("Instruction.state")(() => - Effect.succeed({ - // Track which instruction files have already been attached for a given assistant message. - claims: new Map>(), - }), - ), - ) - - const relative = Effect.fnUntraced(function* (instruction: string) { - const ctx = yield* InstanceState.context - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - return yield* fs - .globUp(instruction, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - } - return yield* fs - .globUp(instruction, global.config, global.config) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - }) - - const read = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) - }) - - const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { - const s = yield* InstanceState.get(state) - s.claims.delete(messageID) - }) - - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const ctx = yield* InstanceState.context - const paths = new Set() - - for (const file of globalFiles) { - if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) - break - } - } - - // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of instructionFiles) { - const matches = yield* fs - .findUp(file, ctx.directory, ctx.worktree) - .pipe(Effect.catch(() => Effect.succeed([]))) - if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) - break - } - } - } - - if (config.instructions) { - for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue - const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw - const matches = yield* ( - path.isAbsolute(instruction) - ? fs.glob(path.basename(instruction), { - cwd: path.dirname(instruction), - absolute: true, - include: "file", - }) - : relative(instruction) - ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) - } - } - - return paths - }) - - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - - // Remote instruction URLs are not honored. Instructions enter context - // only from files on disk the user controls. - - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - - return Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])) - }) - - const find = Effect.fn("Instruction.find")(function* (dir: string) { - for (const file of instructionFiles) { - const filepath = path.resolve(path.join(dir, file)) - if (yield* fs.existsSafe(filepath)) return filepath - } - return undefined - }) - - const resolve = Effect.fn("Instruction.resolve")(function* ( - messages: SessionV1.WithParts[], - filepath: string, - messageID: MessageID, - ) { - const sys = yield* systemPaths() - const already = extract(messages) - const results: { filepath: string; content: string }[] = [] - const s = yield* InstanceState.get(state) - const root = path.resolve(yield* InstanceState.directory) - - const target = path.resolve(filepath) - let current = path.dirname(target) - - // Walk upward from the file being read and attach nearby instruction files once per message. - while (current.startsWith(root) && current !== root) { - const found = yield* find(current) - if (!found || found === target || sys.has(found) || already.has(found)) { - current = path.dirname(current) - continue - } - - let set = s.claims.get(messageID) - if (!set) { - set = new Set() - s.claims.set(messageID, set) - } - if (set.has(found)) { - current = path.dirname(current) - continue - } - - set.add(found) - const content = yield* read(found) - if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) - } - - current = path.dirname(current) - } - - return results - }) - - return Service.of({ clear, systemPaths, system, find, resolve }) - }), -) - -export function loaded(messages: SessionV1.WithParts[]) { - return extract(messages) -} - -export const node = LayerNode.make({ - service: Service, - layer: layer, - deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node], -}) - -export * as Instruction from "./instruction" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 3d1170ea09..fdf2755513 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -8,7 +8,7 @@ import type { Agent } from "@/agent/agent" import type { MessageV2 } from "../message-v2" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" -import { SystemPrompt } from "../system" +import PROMPT_NEURON from "../prompt/neuron.txt" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Record } from "effect" import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai" @@ -58,7 +58,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth" const system = [ [ - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + ...(input.agent.prompt ? [input.agent.prompt] : PROMPT_NEURON), ...input.system, ...(input.user.system ? [input.user.system] : []), ] diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts deleted file mode 100644 index b641b7dd81..0000000000 --- a/packages/opencode/src/session/message.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Schema } from "effect" -import { SessionID } from "./schema" - -import { NonNegativeInt } from "@opencode-ai/core/schema" -import { MessageError } from "./message-error" -import { AuthError, OutputLengthError } from "./message-error" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { ModelV2 } from "@opencode-ai/core/model" -export { AuthError, OutputLengthError } from "./message-error" - -export const ToolCall = Schema.Struct({ - state: Schema.Literal("call"), - step: Schema.optional(NonNegativeInt), - toolCallId: Schema.String, - toolName: Schema.String, - args: Schema.Unknown, -}).annotate({ identifier: "ToolCall" }) -export type ToolCall = Schema.Schema.Type - -export const ToolPartialCall = Schema.Struct({ - state: Schema.Literal("partial-call"), - step: Schema.optional(NonNegativeInt), - toolCallId: Schema.String, - toolName: Schema.String, - args: Schema.Unknown, -}).annotate({ identifier: "ToolPartialCall" }) -export type ToolPartialCall = Schema.Schema.Type - -export const ToolResult = Schema.Struct({ - state: Schema.Literal("result"), - step: Schema.optional(NonNegativeInt), - toolCallId: Schema.String, - toolName: Schema.String, - args: Schema.Unknown, - result: Schema.String, -}).annotate({ identifier: "ToolResult" }) -export type ToolResult = Schema.Schema.Type - -export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult]).annotate({ - identifier: "ToolInvocation", - discriminator: "state", -}) -export type ToolInvocation = Schema.Schema.Type - -export const TextPart = Schema.Struct({ - type: Schema.Literal("text"), - text: Schema.String, -}).annotate({ identifier: "TextPart" }) -export type TextPart = Schema.Schema.Type - -export const ReasoningPart = Schema.Struct({ - type: Schema.Literal("reasoning"), - text: Schema.String, - providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Schema.Schema.Type - -export const ToolInvocationPart = Schema.Struct({ - type: Schema.Literal("tool-invocation"), - toolInvocation: ToolInvocation, -}).annotate({ identifier: "ToolInvocationPart" }) -export type ToolInvocationPart = Schema.Schema.Type - -export const SourceUrlPart = Schema.Struct({ - type: Schema.Literal("source-url"), - sourceId: Schema.String, - url: Schema.String, - title: Schema.optional(Schema.String), - providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}).annotate({ identifier: "SourceUrlPart" }) -export type SourceUrlPart = Schema.Schema.Type - -export const FilePart = Schema.Struct({ - type: Schema.Literal("file"), - mediaType: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, -}).annotate({ identifier: "FilePart" }) -export type FilePart = Schema.Schema.Type - -export const StepStartPart = Schema.Struct({ - type: Schema.Literal("step-start"), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Schema.Schema.Type - -export const MessagePart = Schema.Union([ - TextPart, - ReasoningPart, - ToolInvocationPart, - SourceUrlPart, - FilePart, - StepStartPart, -]).annotate({ identifier: "MessagePart", discriminator: "type" }) -export type MessagePart = Schema.Schema.Type - -export const Info = Schema.Struct({ - id: Schema.String, - role: Schema.Literals(["user", "assistant"]), - parts: Schema.Array(MessagePart), - metadata: Schema.Struct({ - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(MessageError.SharedSchema), - sessionID: SessionID, - tool: Schema.Record( - Schema.String, - Schema.StructWithRest( - Schema.Struct({ - title: Schema.String, - snapshot: Schema.optional(Schema.String), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), - }), - [Schema.Record(Schema.String, Schema.Unknown)], - ), - ), - assistant: Schema.optional( - Schema.Struct({ - system: Schema.Array(Schema.String), - modelID: ModelV2.ID, - providerID: ProviderV2.ID, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - cost: Schema.Finite, - summary: Schema.optional(Schema.Boolean), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - }), - ), - snapshot: Schema.optional(Schema.String), - }).annotate({ identifier: "MessageMetadata" }), -}).annotate({ identifier: "Message" }) -export type Info = Schema.Schema.Type - -export * as Message from "./message" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4bac376466..ae8e4c161a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import os from "os" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" +import { SessionContext } from "./context" import { SessionRevert } from "./revert" import { Session } from "./session" import { Agent } from "../agent/agent" @@ -13,8 +14,6 @@ import { Provider } from "@/provider/provider" import { type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" -import { SystemPrompt } from "./system" -import { Instruction } from "./instruction" import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" @@ -52,7 +51,6 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { eq } from "drizzle-orm" import { SessionTable } from "@opencode-ai/core/session/sql" -import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" @@ -129,15 +127,14 @@ const layer = Layer.effect( const image = yield* Image.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope - const instruction = yield* Instruction.Service const state = yield* SessionRunState.Service const revert = yield* SessionRevert.Service const summary = yield* SessionSummary.Service - const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service const database = yield* Database.Service + const sessionContext = yield* SessionContext.Service const { db } = database const ops = Effect.fn("SessionPrompt.ops")(function* () { return { @@ -671,7 +668,6 @@ const layer = Layer.effect( }) } - yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends SessionV1.Part ? Omit & { id?: string } : never const assign = (part: Draft): SessionV1.Part => ({ @@ -1173,11 +1169,6 @@ const layer = Layer.effect( } const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps - msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe( - Effect.provideService(RuntimeFlags.Service, flags), - Effect.provideService(FSUtil.Service, fsys), - Effect.provideService(Session.Service, sessions), - ) const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -1247,19 +1238,13 @@ const layer = Layer.effect( if (step === 1) yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([ - sys.skills(agent), - sys.environment(model), - instruction.system().pipe(Effect.orDie), - sys.mcp(agent, session.permission), + const [blocks, modelMsgs] = yield* Effect.all([ + sessionContext.system({ agent, modelID: model.api.id }), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [ - ...env, - ...instructions, - ...(mcpInstructions ? [mcpInstructions] : []), - ...(skills ? [skills] : []), - ] + const system = blocks.map( + (block) => `[${block.source}${block.origin ? `:${block.origin}` : ""}]\n${block.text}`, + ) const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ @@ -1321,7 +1306,6 @@ const layer = Layer.effect( } return "continue" as const }).pipe( - Effect.ensuring(instruction.clear(handle.message.id)), Effect.onInterrupt(() => finalizeInterruptedAssistant), ) if (outcome === "break") break @@ -1591,6 +1575,7 @@ export const node = LayerNode.make({ Session.node, Agent.node, Provider.node, + SessionContext.node, SessionProcessor.node, SessionCompaction.node, Command.node, @@ -1603,11 +1588,9 @@ export const node = LayerNode.make({ Truncate.node, Image.node, CrossSpawnSpawner.node, - Instruction.node, SessionRunState.node, SessionRevert.node, SessionSummary.node, - SystemPrompt.node, LLM.node, EventV2Bridge.node, RuntimeFlags.node, diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts deleted file mode 100644 index 2c69d64dd5..0000000000 --- a/packages/opencode/src/session/reminders.ts +++ /dev/null @@ -1,62 +0,0 @@ -import path from "path" -import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Effect } from "effect" -import { Agent } from "@/agent/agent" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { InstanceState } from "@/effect/instance-state" -import { PartID } from "./schema" -import { Session } from "./session" -import BUILD_SWITCH from "./prompt/build-switch.txt" -import PLAN_MODE from "./prompt/plan-mode.txt" - -export const apply = Effect.fn("SessionReminders.apply")(function* (input: { - messages: SessionV1.WithParts[] - agent: Agent.Info - session: Session.Info -}) { - const fsys = yield* FSUtil.Service - const sessions = yield* Session.Service - const userMessage = input.messages.findLast((msg) => msg.info.role === "user") - if (!userMessage) return input.messages - - const ctx = yield* InstanceState.context - const plan = Session.plan(input.session, ctx) - - // leaving plan mode: remind build to execute on the plan file if one exists - const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") - if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { - const exists = yield* fsys.existsSafe(plan) - const part = yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: exists ? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. Execute on the plan defined within it.` : BUILD_SWITCH, - synthetic: true, - }) - userMessage.parts.push(part) - return input.messages - } - - // entering plan mode: hand over the plan file location and workflow - if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages - - const exists = yield* fsys.existsSafe(plan) - if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) - const part = yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: userMessage.info.id, - sessionID: userMessage.info.sessionID, - type: "text", - text: PLAN_MODE.replace("${planInfo}", () => - exists - ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` - : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`, - ), - synthetic: true, - }) - userMessage.parts.push(part) - return input.messages -}) - -export * as SessionReminders from "./reminders" diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts deleted file mode 100644 index ba33ca4bdb..0000000000 --- a/packages/opencode/src/session/system.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Context, Effect, Layer } from "effect" - -import { InstanceState } from "@/effect/instance-state" - -import PROMPT_NEURON from "./prompt/neuron.txt" -import type { Provider } from "@/provider/provider" -import type { Agent } from "@/agent/agent" -import { Permission } from "@/permission" -import { Skill } from "@/skill" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" -import { Reference } from "@opencode-ai/core/reference" -import { MCP } from "@/mcp" -import { PermissionV1 } from "@opencode-ai/core/v1/permission" - -export function provider(model: Provider.Model) { - return [PROMPT_NEURON] -} - -export interface Interface { - readonly environment: (model: Provider.Model) => Effect.Effect - readonly skills: (agent: Agent.Info) => Effect.Effect - readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SystemPrompt") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const skill = yield* Skill.Service - const mcp = yield* MCP.Service - const locations = yield* LocationServiceMap.Service - - return Service.of({ - environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { - const ctx = yield* InstanceState.context - const references = yield* Effect.gen(function* () { - return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) - }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) - return [ - [ - `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, - `Here is some useful information about the environment you are running in:`, - ``, - ` Working directory: ${ctx.directory}`, - ` Workspace root folder: ${ctx.worktree}`, - ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`, - ` Platform: ${process.platform}`, - ` Today's date: ${new Date().toDateString()}`, - ``, - ].join("\n"), - references.length === 0 - ? undefined - : [ - "Project references provide additional directories that can be accessed when relevant.", - "", - ...references - .toSorted((a, b) => a.name.localeCompare(b.name)) - .flatMap((reference) => [ - " ", - ` ${reference.name}`, - ` ${reference.path}`, - ...(reference.description === undefined - ? [] - : [` ${reference.description}`]), - " ", - ]), - "", - ].join("\n"), - ].filter((part): part is string => part !== undefined) - }), - - skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { - if (Permission.disabled(["skill"], agent.permission).has("skill")) return - - const list = yield* skill.available(agent) - - return [ - "Skills provide specialized instructions and workflows for specific tasks.", - "Use the skill tool to load a skill when a task matches its description.", - // the agents seem to ingest the information about skills a bit better if we present a more verbose - // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(list, { verbose: true }), - ].join("\n") - }), - - mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { - const ruleset = Permission.merge(agent.permission, permission ?? []) - const instructions = (yield* mcp.instructions()).filter( - (item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length, - ) - if (instructions.length === 0) return - - return [ - "", - ...instructions.flatMap((item) => [ - ` `, - ...item.instructions.split("\n").map((line) => ` ${line}`), - " ", - ]), - "", - ].join("\n") - }), - }) - }), -) - -const locationServiceMapNode = LayerNode.make({ - service: LocationServiceMap.Service, - layer: locationServiceMapLayer, - deps: [], -}) - -export const node = LayerNode.make({ - service: Service, - layer: layer, - deps: [Skill.node, MCP.node, locationServiceMapNode], -}) - -export * as SystemPrompt from "./system" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 678ed44510..8017577511 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -7,7 +7,6 @@ import { LSP } from "@/lsp/lsp" import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" -import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 @@ -57,19 +56,17 @@ type Display = type Metadata = { preview: string truncated: boolean - loaded: string[] display?: Display } export const ReadTool = Tool.define< typeof Parameters, Metadata, - FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope + FSUtil.Service | LSP.Service | Scope.Scope >( "read", Effect.gen(function* () { const fs = yield* FSUtil.Service - const instruction = yield* Instruction.Service const lsp = yield* LSP.Service const scope = yield* Scope.Scope @@ -297,7 +294,6 @@ export const ReadTool = Tool.define< } } - const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath)) @@ -312,7 +308,6 @@ export const ReadTool = Tool.define< metadata: { preview: msg, truncated: false, - loaded: loaded.map((item) => item.filepath), }, attachments: [ { @@ -352,17 +347,12 @@ export const ReadTool = Tool.define< yield* warm(filepath) - if (loaded.length > 0) { - output += `\n\n\n${loaded.map((item) => item.content).join("\n\n")}\n` - } - return { title, output, metadata: { preview: file.raw.slice(0, 20).join("\n"), truncated, - loaded: loaded.map((item) => item.filepath), display: { type: "file" as const, path: filepath, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ebde7fbcb..b0e059dd91 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -46,7 +46,6 @@ import { EffectBridge } from "@/effect/bridge" import { Question } from "../question" import { Todo } from "../session/todo" import { LSP } from "@/lsp/lsp" -import { Instruction } from "../session/instruction" import { FSUtil } from "@opencode-ai/core/fs-util" import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" @@ -441,7 +440,6 @@ export const node = LayerNode.make({ BackgroundJob.node, Provider.node, LSP.node, - Instruction.node, FSUtil.node, EventV2Bridge.node, httpClient, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8f72c0cb7f..b599ba268c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1031,7 +1031,6 @@ it.effect("global config remains global when project config is disabled", () => Effect.gen(function* () { const config = yield* Config.use.get() expect(config.model).toBe("global/model") - expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global") }), ), ), @@ -1108,30 +1107,6 @@ it.effect("deduplicates duplicate plugins from global and local configs", () => ), ) -it.effect("keeps plugin origins aligned with merged plugin list", () => - withConfigTree( - { - global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] }, - local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] }, - }, - Effect.gen(function* () { - const config = yield* Config.use.get() - const plugins = config.plugin ?? [] - const origins = config.plugin_origins ?? [] - const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) - - expect(names).toContain("shared-plugin@2.0.0") - expect(names).not.toContain("shared-plugin@1.0.0") - expect(names).toContain("global-only@1.0.0") - expect(names).toContain("local-only@1.0.0") - expect(origins.map((item) => item.spec)).toEqual(plugins) - expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe( - "local", - ) - }), - ), -) - // Legacy tools migration tests it.instance("migrates legacy tools config to permissions - allow", () => diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index cc43cf4e55..7685e9142e 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -7,7 +7,6 @@ import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Config } from "@/config/config" -import { ConfigPlugin } from "@/config/plugin" import { CurrentWorkingDirectory } from "@/config/tui-cwd" import { TuiConfig } from "../../src/config/tui" import { TestInstance } from "../fixture/fixture" @@ -76,53 +75,7 @@ const getTuiConfig = (directory: string) => ), ) -const getTuiPluginOrigins = (directory: string) => - TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide( - AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), - ), - ) - -it.instance("keeps server and tui plugin merge semantics aligned", () => - withCleanState( - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const test = yield* TestInstance - const local = path.join(test.directory, ".opencode") - yield* fs.makeDirectory(local, { recursive: true }) - - yield* fs.writeJson(path.join(Global.Path.config, "opencode.json"), { - plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], - }) - yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { - plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], - }) - yield* fs.writeJson(path.join(local, "opencode.json"), { - plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], - }) - yield* fs.writeJson(path.join(local, "tui.json"), { - plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], - }) - - const server = yield* Config.use.get() - const tui = yield* getTuiConfig(test.directory) - const tuiOrigins = yield* getTuiPluginOrigins(test.directory) - const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) - const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item)) - - expect(serverPlugins).toEqual(tuiPlugins) - expect(serverPlugins).toContain("shared-plugin@2.0.0") - expect(serverPlugins).not.toContain("shared-plugin@1.0.0") - - const serverOrigins = server.plugin_origins ?? [] - expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins) - expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins) - expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope)) - }), - ), -) - -it.instance("loads tui config with the same precedence order as server config paths", () => +it.instance("merges plugin_enabled flags across config layers", () => withCleanState( Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -739,93 +692,6 @@ it.instance("loads .opencode/tui.json", () => ), ) -it.instance("supports tuple plugin specs with options in tui.json", () => - withCleanState( - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const test = yield* TestInstance - yield* fs.writeJson(path.join(test.directory, "tui.json"), { - plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]], - }) - - const config = yield* getTuiConfig(test.directory) - const origins = yield* getTuiPluginOrigins(test.directory) - expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) - expect(origins).toEqual([ - { - spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }], - scope: "local", - source: path.join(test.directory, "tui.json"), - }, - ]) - }), - ), -) - -it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () => - withCleanState( - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const test = yield* TestInstance - yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { - plugin: [["acme-plugin@1.0.0", { source: "global" }]], - }) - yield* fs.writeJson(path.join(test.directory, "tui.json"), { - plugin: [ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ], - }) - - const config = yield* getTuiConfig(test.directory) - const origins = yield* getTuiPluginOrigins(test.directory) - expect(config.plugin).toEqual([ - ["acme-plugin@2.0.0", { source: "project" }], - ["second-plugin@3.0.0", { source: "project" }], - ]) - expect(origins).toEqual([ - { - spec: ["acme-plugin@2.0.0", { source: "project" }], - scope: "local", - source: path.join(test.directory, "tui.json"), - }, - { - spec: ["second-plugin@3.0.0", { source: "project" }], - scope: "local", - source: path.join(test.directory, "tui.json"), - }, - ]) - }), - ), -) - -it.instance("tracks global and local plugin metadata in merged tui config", () => - withCleanState( - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const test = yield* TestInstance - yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] }) - yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] }) - - const config = yield* getTuiConfig(test.directory) - const origins = yield* getTuiPluginOrigins(test.directory) - expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"]) - expect(origins).toEqual([ - { - spec: "global-plugin@1.0.0", - scope: "global", - source: path.join(Global.Path.config, "tui.json"), - }, - { - spec: "local-plugin@2.0.0", - scope: "local", - source: path.join(test.directory, "tui.json"), - }, - ]) - }), - ), -) - it.instance("merges plugin_enabled flags across config layers", () => withCleanState( Effect.gen(function* () { diff --git a/packages/opencode/test/fixture/plugin-meta-worker.ts b/packages/opencode/test/fixture/plugin-meta-worker.ts deleted file mode 100644 index c02b448ae7..0000000000 --- a/packages/opencode/test/fixture/plugin-meta-worker.ts +++ /dev/null @@ -1,19 +0,0 @@ -const raw = process.argv[2] -if (!raw) throw new Error("Missing worker payload") - -const value = JSON.parse(raw) -if (!value || typeof value !== "object") { - throw new Error("Invalid worker payload") -} - -const msg = Object.fromEntries(Object.entries(value)) -if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") { - throw new Error("Invalid worker payload") -} -if (typeof msg.id !== "string") throw new Error("Invalid worker payload") - -process.env.OPENCODE_PLUGIN_META_FILE = msg.file - -const { PluginMeta } = await import("../../src/plugin/meta") - -await PluginMeta.touch(msg.spec, msg.target, msg.id)