Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 2bc9f6ea09 refactor(plugin): isolate effect authoring runtime 2026-08-24 13:44:14 -04:00
237 changed files with 3118 additions and 7566 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Compile Effect-authored plugins to the runtime-neutral Promise and Standard Schema plugin contract.
-9
View File
@@ -1,9 +0,0 @@
---
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
"@opencode-ai/plugin": minor
"@opencode-ai/schema": minor
"@opencode-ai/sdk": minor
---
Expose process-local execution status on session get responses.
+3 -1
View File
@@ -579,7 +579,6 @@
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
@@ -590,6 +589,7 @@
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
},
@@ -597,12 +597,14 @@
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"effect": "catalog:",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"@opencode-ai/theme",
"@opentui/core",
"@opentui/solid",
"effect",
"solid-js",
],
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
}
}
@@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.String,
signature: Schema.optional(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -701,26 +701,6 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -827,30 +807,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
// thinking; only signature-less parts carrying redactedData
// round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
+2 -9
View File
@@ -666,7 +666,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -684,18 +683,11 @@ const lowerOptions = (request: LLMRequest) => {
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
export const resolveParallelToolCalls = (request: LLMRequest) => {
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
if (configured !== undefined) return configured
const disabled = request.toolChoice?.disableParallelToolUse
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
@@ -1194,6 +1186,7 @@ export const step = (state: ParserState, event: Event) => {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
+7 -143
View File
@@ -51,12 +51,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -138,7 +133,6 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -270,18 +264,12 @@ interface LoweringOptions {
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -540,122 +528,11 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
@@ -674,19 +551,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
@@ -700,13 +566,11 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -716,7 +580,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request),
}
})
@@ -111,10 +111,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -164,7 +162,7 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
-5
View File
@@ -155,11 +155,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+1 -1
View File
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_completion_tokens: 30,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -24,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
},
"response": {
"status": 200,
@@ -35,4 +29,4 @@
}
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,15 +18,6 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
cache: "none",
}),
)
const request = LLM.request({
id: "req_1",
model,
@@ -573,66 +564,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("demotes unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
auth: Auth.bearer("test"),
})
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}),
)
it.effect("lets an explicit signature requirement override inference", () =>
Effect.gen(function* () {
const compatible = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
auth: Auth.header("x-api-key", "test"),
})
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are concise." },
@@ -55,8 +55,7 @@ describe("OpenAI Chat route", () => {
],
stream: true,
stream_options: { include_usage: true },
store: false,
max_completion_tokens: 20,
max_tokens: 20,
temperature: 0,
})
}),
@@ -326,7 +325,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -346,7 +345,6 @@ describe("OpenAI Chat route", () => {
tools: [],
stream: true,
stream_options: { include_usage: true },
store: false,
})
}),
)
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
tools: [
{
type: "function",
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
},
],
tool_choice: "required",
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -158,29 +158,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
Effect.gen(function* () {
const prepare = (provider: string, baseURL: string, id: string) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
prompt: "Use a tool.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
expect(current.body).toMatchObject({ tool_stream: true })
const legacy = yield* Effect.all(
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
),
)
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -203,7 +180,7 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -227,7 +204,6 @@ describe("OpenAI-compatible Chat route", () => {
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
strict: false,
},
},
],
@@ -132,40 +132,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Read the file.",
tools: [
ToolDefinition.make({
name: "read",
description: "Read a file.",
inputSchema: { type: "object" },
}),
],
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
expect(prepared.body.parallel_tool_calls).toBe(false)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "read",
description: "Read a file.",
parameters: { type: "object" },
strict: false,
},
])
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
@@ -258,31 +258,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps the canonical parallel tool setting with provider-option precedence", () =>
Effect.gen(function* () {
const disabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
const enabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: false },
}),
)
const overridden = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
providerOptions: { parallelToolCalls: true },
}),
)
expect(disabled.body.parallel_tool_calls).toBe(false)
expect(enabled.body.parallel_tool_calls).toBe(true)
expect(overridden.body.parallel_tool_calls).toBe(true)
}),
)
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -1
View File
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
@@ -20,47 +20,45 @@ const messages = [
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const session = {
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
}
const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
pageMessages: () => ({ items: messages }),
}
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -79,19 +77,3 @@ test("reverts directly to the selected user message", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
test("hides revert actions in a child session", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
sessions: [
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
{ ...session, parentID: "ses_parent" },
],
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})
@@ -126,8 +126,6 @@ test("routes typing to the composer unless the open terminal is focused", async
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await composer.click()
await expect(composer).toBeFocused()
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
+8 -1
View File
@@ -274,8 +274,15 @@ async function sendCommand(
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
text: command.arguments,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
@@ -193,9 +193,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openTerminal = () => {
actions.session.layout.view().terminal.open()
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
actions.session.layout.view().terminal.open()
}
const closeTerminal = () => {
@@ -361,8 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
actions.session.layout.view().terminal.close()
return
}
actions.session.layout.view().terminal.open()
terminal.requestFocus(terminal.active())
actions.session.layout.view().terminal.open()
},
}),
viewCommand({
+1 -5
View File
@@ -37,11 +37,7 @@ export function createActiveComposerAdapter(input: {
current: () => data.session.get(id),
admitted: (messageID) => data.session.input.has(id, messageID) || !!data.session.message.get(id, messageID),
}),
interrupt: () =>
server.api.session
.interrupt({ sessionID: id, continue: true })
.then(() => undefined)
.catch(() => undefined),
interrupt: () => server.api.session.interrupt({ sessionID: id, continue: true }).catch(() => undefined),
}
return adapter
}
+1 -8
View File
@@ -156,7 +156,6 @@ export function createActiveSessionRegion(input: {
session: input.session,
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
useComposerCommands()
useSessionCommands({
session: input.session,
@@ -179,13 +178,7 @@ export function createActiveSessionRegion(input: {
return {
actions: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
openAttachment,
} satisfies SessionUserActions,
timeline: { revert: ({ messageID }) => revert.to(messageID), openAttachment } satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
-6
View File
@@ -76,7 +76,6 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
@@ -346,11 +345,6 @@ export async function streamTurn(input: {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
+2 -2
View File
@@ -326,7 +326,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
@@ -378,8 +377,9 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
text: prompt.slash?.args ?? "",
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
-1
View File
@@ -1,5 +1,4 @@
import "./plugin-runtime.promise"
import "./plugin-runtime.effect"
process.stdout.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
@@ -1,28 +0,0 @@
import {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
Agent,
Command,
Connection,
Credential,
Integration,
Model,
Plugin,
Provider,
Reference,
Skill,
Tool: { Error: Tool.Error },
}
+1 -2
View File
@@ -601,7 +601,6 @@ describe("acp event behavior", () => {
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
return true
},
})
const result = streamTurn({
@@ -625,7 +624,7 @@ describe("acp event behavior", () => {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
-39
View File
@@ -121,42 +121,3 @@ test("acp prompt resolves after ordered turn updates", async () => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
fetch(request) {
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
try {
const response = await streamTurn({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
} finally {
await server.stop(true)
}
})
+1
View File
@@ -87,6 +87,7 @@ export const planAgent = {
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
+9 -2
View File
@@ -12,7 +12,13 @@ describe("acp service prompt routing and usage", () => {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
@@ -59,8 +65,9 @@ describe("acp service prompt routing and usage", () => {
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
text: "now",
arguments: "now",
files: [],
delivery: "steer",
})
+3 -4
View File
@@ -20,7 +20,7 @@ type FixtureOptions = {
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => boolean | Promise<boolean>
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
@@ -152,9 +152,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
const interrupted =
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
return Response.json({ interrupted })
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
+2 -5
View File
@@ -29,10 +29,7 @@ afterEach(() => mock.restore())
describe("session target resolver", () => {
test("adopts an explicit Session location and model", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = {
...session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" }),
executing: false,
}
const selected = session("ses_resume", "/session", "work_1", { providerID: "openai", id: "gpt-5" })
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session", "work_1"))
@@ -60,7 +57,7 @@ describe("session target resolver", () => {
test("attaches the terminal environment to the resolved local Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const selected = { ...session("ses_resume", "/session"), executing: false }
const selected = session("ses_resume", "/session")
spyOn(client.session, "get").mockResolvedValue(selected)
spyOn(client.location, "get").mockResolvedValue(location("/session"))
const environment = spyOn(client.session, "environment").mockResolvedValue()
-16
View File
@@ -132,20 +132,10 @@ export const Plugin = sdk.Plugin
export const Provider = sdk.Provider
export const Reference = sdk.Reference
export const Skill = sdk.Skill`
const effectModule = promiseModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promisePluginModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `export {}`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Error = sdk.Tool.Error
`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -160,17 +150,11 @@ const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
})}
__cjs_mod__.registerHooks({
resolve(__ocSpecifier, __ocContext, __ocNextResolve) {
+13 -23
View File
@@ -203,7 +203,7 @@ export type SessionActiveOutput = { readonly [x: Session.ID]: { readonly type: "
export type SessionActiveOperation<E = never> = () => Effect.Effect<SessionActiveOutput, E>
export type SessionGetInput = { readonly sessionID: Session.ID }
export type SessionGetOutput = Session.GetInfo
export type SessionGetOutput = Session.Info
export type SessionGetOperation<E = never> = (input: SessionGetInput) => Effect.Effect<SessionGetOutput, E>
export type SessionRemoveInput = { readonly sessionID: Session.ID }
@@ -255,14 +255,18 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
export type SessionCommandInput = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
readonly text: string
readonly arguments?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type SessionCommandOutput = void
export type SessionCommandOutput = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -998,7 +1002,7 @@ export type SessionLogOutput =
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
export type SessionInterruptInput = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type SessionInterruptOutput = { readonly interrupted: boolean }
export type SessionInterruptOutput = void
export type SessionInterruptOperation<E = never> = (
input: SessionInterruptInput,
) => Effect.Effect<SessionInterruptOutput, E>
@@ -1108,7 +1112,11 @@ export interface ModelApi<E = never> {
readonly default: ModelDefaultOperation<E>
}
export type GenerateTextInput = { readonly prompt: string; readonly model?: Model.Ref | undefined }
export type GenerateTextInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string
readonly model?: Model.Ref | undefined
}
export type GenerateTextOutput = { readonly text: string }
export type GenerateTextOperation<E = never> = (input: GenerateTextInput) => Effect.Effect<GenerateTextOutput, E>
@@ -1691,23 +1699,6 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type WorkspaceCreateInput = { readonly id?: Workspace.ID | undefined; readonly provider: string }
export type WorkspaceCreateOutput = Workspace.ID
export type WorkspaceCreateOperation<E = never> = (
input: WorkspaceCreateInput,
) => Effect.Effect<WorkspaceCreateOutput, E>
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
export type WorkspaceDestroyOutput = Workspace.DestroyResult
export type WorkspaceDestroyOperation<E = never> = (
input: WorkspaceDestroyInput,
) => Effect.Effect<WorkspaceDestroyOutput, E>
export interface WorkspaceApi<E = never> {
readonly create: WorkspaceCreateOperation<E>
readonly destroy: WorkspaceDestroyOperation<E>
}
export type VcsGetInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1825,7 +1816,6 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly workspace: WorkspaceApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+13 -26
View File
@@ -214,10 +214,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -444,14 +440,21 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
}).pipe(Effect.mapError(mapClientError)),
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -721,7 +724,10 @@ const adaptGroupModel = (raw: RawClient["server.model"]) => ({
const EndpointGenerateText = (raw: RawClient["server.generate"]) => (input: GenerateTextInput) =>
preserveEffect<GenerateTextOutput>()(
raw["generate.text"]({ payload: { prompt: input["prompt"], model: input["model"] } }).pipe(
raw["generate.text"]({
query: { location: input["location"] },
payload: { prompt: input["prompt"], model: input["model"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -1272,24 +1278,6 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointWorkspaceCreate = (raw: RawClient["server.workspace"]) => (input: WorkspaceCreateInput) =>
preserveEffect<WorkspaceCreateOutput>()(
raw["workspace.create"]({ payload: { id: input["id"], provider: input["provider"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
preserveEffect<WorkspaceDestroyOutput>()(
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({
create: EndpointWorkspaceCreate(raw),
destroy: EndpointWorkspaceDestroy(raw),
})
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1380,7 +1368,6 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
workspace: adaptGroupWorkspace(raw["server.workspace"]),
vcs: adaptGroupVcs(raw["server.vcs"]),
debug: adaptGroupDebug(raw["server.debug"]),
migration: adaptGroupMigration(raw["server.migration"]),
+13 -37
View File
@@ -210,10 +210,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -635,24 +631,28 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<SessionCommandOutput>(
request<{ readonly data: SessionCommandOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
empty: true,
successStatus: 200,
declaredStatuses: [409, 400, 404, 500, 401],
empty: false,
},
requestOptions,
),
).then((value) => value.data),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
@@ -880,9 +880,9 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
query: { continue: input["continue"] },
successStatus: 200,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: false,
empty: true,
},
requestOptions,
),
@@ -979,6 +979,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/generate`,
query: { location: input["location"] },
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
@@ -1769,31 +1770,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
workspace: {
create: (input: WorkspaceCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: WorkspaceCreateOutput }>(
{
method: "POST",
path: `/api/workspace`,
body: { id: input["id"], provider: input["provider"] },
successStatus: 200,
declaredStatuses: [409, 404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
request<WorkspaceDestroyOutput>(
{
method: "DELETE",
path: `/api/workspace/${encodeURIComponent(input.workspaceID)}`,
successStatus: 200,
declaredStatuses: [500, 401, 400],
empty: false,
},
requestOptions,
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
+143 -50
View File
@@ -176,8 +176,6 @@ export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?:
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
@@ -313,8 +311,6 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type CommandInfo = { name: string; description?: string }
export type SkillInfo = {
id: string
name: string
@@ -382,8 +378,6 @@ export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = {
@@ -397,6 +391,15 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type CommandInfo = {
name: string
template: string
description?: string
agent?: string
model?: ModelRef
subtask?: boolean
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -1545,24 +1548,6 @@ export type SessionInfo = {
revert?: SessionRevert
}
export type SessionGetInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
revert?: SessionRevert
executing: boolean
}
export type SessionRevertStaged = {
id: string
created: number
@@ -2235,13 +2220,13 @@ export type CommandNotFoundError = {
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
export type CommandExecutionError = {
readonly _tag: "CommandExecutionError"
export type CommandEvaluationError = {
readonly _tag: "CommandEvaluationError"
readonly command: string
readonly message: string
}
export const isCommandExecutionError = (value: unknown): value is CommandExecutionError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError"
export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError"
export type SkillNotFoundError = {
readonly _tag: "SkillNotFoundError"
@@ -3417,7 +3402,7 @@ export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionGetOutput = { data: SessionGetInfo }["data"]
export type SessionGetOutput = { data: SessionInfo }["data"]
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -3652,9 +3637,35 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["id"]
readonly command: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3670,10 +3681,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["command"]
readonly text: {
readonly arguments?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3689,10 +3704,60 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
}["text"]
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3708,10 +3773,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3727,10 +3796,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3746,10 +3819,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3765,10 +3842,34 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionCommandOutput = void
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3952,7 +4053,7 @@ export type SessionInterruptInput = {
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
}
export type SessionInterruptOutput = SessionInterruptResponse
export type SessionInterruptOutput = void
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -4023,6 +4124,9 @@ export type ModelDefaultOutput = {
}
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -5648,17 +5752,6 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type WorkspaceCreateInput = {
readonly id?: { readonly id?: string | undefined; readonly provider: string }["id"]
readonly provider: { readonly id?: string | undefined; readonly provider: string }["provider"]
}
export type WorkspaceCreateOutput = { data: string }["data"]
export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspaceID: string }["workspaceID"] }
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type SessionGetOutput = Session.GetInfo")
expect(source).toContain("export type SessionGetOutput = Session.Info")
expect(source).toContain("export type EventSubscribeOutput = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
+3 -13
View File
@@ -37,7 +37,6 @@ test("session.get returns the decoded Effect projection", async () => {
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
expect(result.executing).toBe(true)
})
test("session instructions methods use the public HTTP contract", async () => {
@@ -173,14 +172,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.includes("/interrupt")
? Response.json({ interrupted: true })
: new Response(null, { status: 204 }),
),
)
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
@@ -210,12 +202,12 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const log = yield* client.session
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
.pipe(Stream.runCollect)
const interrupted = yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, active, created, admitted, context, log, interrupted, message }
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
const listed = result.page.data[0]
@@ -224,7 +216,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(result.interrupted).toEqual({ interrupted: true })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
@@ -280,7 +271,6 @@ const session = {
},
title: "Test",
location: { directory: "/tmp/project" },
executing: true,
},
}
+1 -36
View File
@@ -30,7 +30,6 @@ test("exposes every standard HTTP API group", () => {
"question",
"reference",
"worktree",
"workspace",
"vcs",
"debug",
"migration",
@@ -82,21 +81,6 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("generate.text uses the locationless public contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { text: "pong" } })
},
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -296,21 +280,6 @@ test("worktree methods use the global project contract", async () => {
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ destroyed: false })
},
})
expect(await client.workspace.destroy({ workspaceID: "wrk_missing" })).toEqual({ destroyed: false })
expect(request?.method).toBe("DELETE")
expect(request?.url).toBe("http://localhost:3000/api/workspace/wrk_missing")
})
test("shell list and remove use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const shell = {
@@ -360,7 +329,6 @@ test("session.get returns the wire projection", async () => {
const result = await client.session.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
expect(result.executing).toBe(true)
})
test("session instructions methods use the public HTTP contract", async () => {
@@ -548,7 +516,6 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (url.includes("/interrupt")) return Response.json({ interrupted: true })
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
},
@@ -580,7 +547,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -589,7 +556,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(interrupted).toEqual({ interrupted: true })
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced])
@@ -681,7 +647,6 @@ const session = {
},
title: "Test",
location: { directory: "/tmp/project" },
executing: true,
},
}
+223 -68
View File
@@ -1,32 +1,26 @@
export * as Command from "./command.js"
import { Command } from "@opencode-ai/schema/command"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Bus } from "./bus.js"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state.js"
import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
export const Info = Command.Info
export type Info = Command.Info
export { Event } from "@opencode-ai/schema/command"
export interface Invocation {
readonly sessionID: Session.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
export type Evaluation = {
readonly text: string
}
export interface Definition {
readonly name: string
readonly description?: string
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Draft = {
add: (definition: Definition) => void
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
@@ -34,73 +28,234 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
message: Schema.String,
}) {}
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
command: Schema.String,
message: Schema.String,
}) {}
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
readonly execute: (input: {
readonly evaluate: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<void, NotFoundError | ExecutionError>
readonly arguments?: string
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Map<string, Definition>, Draft>({
name: "command",
initial: () => new Map(),
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
name: definition.name,
description: definition.description,
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
},
remove: (name) => {
draft.commands.delete(name)
},
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
return (yield* mcp.prompts()).map((prompt) =>
Info.make({
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
}),
)
})
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")((name) =>
Effect.sync(() => {
const definition = state.get().get(name)
return definition ? info(definition) : undefined
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")(function* (name) {
const command = staticCommand(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}),
),
list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))),
execute: Effect.fn("Command.execute")(function* (input) {
const definition = state.get().get(input.name)
if (!definition)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
return yield* definition.execute(input.invocation).pipe(
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
list: Effect.fn("Command.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const names = new Set(commands.map((command) => command.name))
return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
}),
evaluate: Effect.fn("Command.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
location,
processes,
shell,
})
const prompt = (yield* mcp.prompts()).find(
(prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
)
if (!prompt)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
const result = yield* mcp
.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.arguments ?? "")[index] ?? "",
]),
),
})
.pipe(
Effect.catchTag("MCP.NotFoundError", () =>
Effect.fail(
new EvaluationError({
command: input.name,
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
}),
),
),
)
if (!result)
return yield* new EvaluationError({
command: input.name,
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
})
return {
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
}
}),
})
}),
)
function evaluateTemplate(
command: string,
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const expanded = evaluateArguments(template, input)
return { text: yield* evaluateShell(command, expanded, services) }
})
}
function evaluateArguments(template: string, input: string) {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
return `${withArguments}\n\n${input}`.trim()
return withArguments.trim()
}
const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{
combineOutput: true,
},
)
}),
})
}),
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) =>
new EvaluationError({
command,
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
}),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
return error.message
return "Command execution failed"
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigAgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
+13 -110
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { define } from "@opencode-ai/core/plugin/definition"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Command } from "../../command.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,6 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -60,41 +51,17 @@ export const Plugin = define({
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined)
item.model = {
id: command.model.model,
providerID: command.model.providerID,
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
@@ -147,67 +114,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
@@ -1,6 +1,6 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
@@ -1,6 +1,6 @@
export * as ConfigInstructionPlugin from "./instruction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
@@ -1,6 +1,6 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigMCPPlugin from "./mcp.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Effect, Stream } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSkillPlugin from "./skill.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
@@ -1,6 +1,6 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "./entry-observer.js"
+3 -4
View File
@@ -2,7 +2,7 @@ export * as MCP from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -19,7 +19,6 @@ import { State } from "../state.js"
import type { MCPClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
export type ServerName = typeof ServerName.Type
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
@@ -454,7 +453,7 @@ export const layer = (options?: Options) =>
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
Effect.andThen(bus.publish(Command.Event.Updated, {})),
)
// Runs a connection callback under the server lock, dropping it if the connection is no longer
@@ -573,7 +572,7 @@ export const layer = (options?: Options) =>
yield* Scope.close(scope, Exit.void)
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
})
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
+1 -1
View File
@@ -2,7 +2,7 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/core/plugin/definition"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as AgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Agent } from "../agent.js"
import { Permission } from "../permission.js"
+8 -99
View File
@@ -1,10 +1,8 @@
export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,104 +10,15 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
})
draft.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
})
for (const prompt of loaded.prompts) {
draft.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
})
}),
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const quoteTrimRegex = /^["']|["']$/g
+15
View File
@@ -0,0 +1,15 @@
import type { Context as PluginContext, Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import type { Scope } from "effect"
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export function define<R>(plugin: PluginDefinition<R>) {
return plugin
}
export namespace Plugin {
export type Context = PluginContext
export type Plugin<R = Scope.Scope> = PluginDefinition<R>
export const define = <R>(plugin: PluginDefinition<R>) => plugin
}
+4 -11
View File
@@ -1,11 +1,11 @@
export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Plugin } from "@opencode-ai/core/plugin/definition"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream, Struct } from "effect"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Catalog } from "../catalog.js"
@@ -394,11 +394,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) =>
Effect.gen(function* () {
const info = yield* runtime.session.get(input.sessionID)
return Struct.assign(info, { executing: (yield* runtime.session.active).has(info.id) })
}),
get: (input) => runtime.session.get(input.sessionID),
switchAgent: runtime.session.switchAgent,
switchModel: runtime.session.switchModel,
prompt: runtime.session.prompt,
@@ -406,10 +402,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) =>
runtime.session
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
+2 -2
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
@@ -236,7 +236,6 @@ const pre = [
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
@@ -275,6 +274,7 @@ const post = [
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
PlanPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
@@ -1,6 +1,6 @@
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "@opencode-ai/schema/integration"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Global } from "@opencode-ai/util/global"
import { Effect, Stream } from "effect"
import path from "path"
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type MantleSDK = {
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const AnthropicPlugin = define({
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const CerebrasPlugin = define({
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect, Option, Schema } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Form } from "@opencode-ai/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import { Effect } from "effect"
@@ -8,7 +8,7 @@ import { App } from "../../app.js"
import { Agent } from "../../agent.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
+1 -1
View File
@@ -1,7 +1,7 @@
import os from "os"
import { App } from "../../app.js"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const GitLabPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
function resolveProject(options: Record<string, any>) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const KiloPlugin = define({
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const NvidiaPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai.compatible",
+7 -68
View File
@@ -1,7 +1,6 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
@@ -13,9 +12,6 @@ import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const callbackFallbackPort = 1457
const callbackBindAttempts = 10
const callbackBindRetryDelay = 200
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
@@ -59,10 +55,11 @@ const browser = (app: App.Info) =>
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost")
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
@@ -89,9 +86,11 @@ const browser = (app: App.Info) =>
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
})
const port = yield* listen(server)
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://localhost:${port}/auth/callback`
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
@@ -105,66 +104,6 @@ const browser = (app: App.Info) =>
refresh: (value) => refresh(browserMethodID, value, app),
}) satisfies IntegrationOAuthMethodRegistration
function listen(server: Server) {
return bind(server, callbackPort).pipe(
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
cancel(callbackPort).pipe(
Effect.ignore,
Effect.andThen(Effect.sleep(callbackBindRetryDelay)),
Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)),
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe(
Effect.as(callbackFallbackPort),
Effect.catchIf(addressInUse, () =>
Effect.fail(
new Error(
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
),
),
),
),
),
),
),
)
}
function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect<void, Error> {
return bind(server, port).pipe(
Effect.catchIf(
(error) => addressInUse(error) && attempts > 1,
() => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))),
),
)
}
function bind(server: Server, port: number) {
return Effect.callback<void, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(port, "localhost", () => {
server.off("error", onError)
resume(Effect.void)
})
})
}
function cancel(port: number) {
return Effect.tryPromise({
try: (signal) =>
fetch(`http://localhost:${port}/cancel`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
}),
catch: (cause) => cause,
})
}
function addressInUse(error: Error) {
return "code" in error && error.code === "EADDRINUSE"
}
const headless = (app: App.Info) =>
({
integrationID: Integration.ID.make("openai"),
@@ -1,7 +1,7 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
export const OpenRouterPlugin = define({
id: "opencode.provider.openrouter",
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Npm } from "@opencode-ai/util/npm"
import { Provider } from "../../provider.js"
import { importModule } from "@opencode-ai/util/runtime-import"
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const VercelPlugin = define({
+1 -1
View File
@@ -1,4 +1,4 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/core/plugin/definition"
import { Provider } from "../../provider.js"
export const ZenmuxPlugin = define({
-2
View File
@@ -14,7 +14,6 @@ export interface Interface {
readonly session: Pick<
Session.Interface,
| "get"
| "active"
| "create"
| "messages"
| "prompt"
@@ -70,7 +69,6 @@ export const layerWithCell = (cell: Cell) =>
Service.of({
session: {
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
active: require(cell, (runtime) => runtime.session.active),
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)),
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import type { Plugin } from "@opencode-ai/core/plugin/definition"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
+1 -1
View File
@@ -2,7 +2,7 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { define, type Context } from "@opencode-ai/core/plugin/definition"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
+5 -7
View File
@@ -138,18 +138,16 @@ V2 config uses more ergonomic shapes, but conversion is optional. When the user
requests conversion, inspect the complete configuration, preserve behavior and
unrelated settings, and apply only the relevant migrations from the guide. For
plugin migrations, fetch and follow both the migration guide and the full
[plugins guide](https://opencode.ai/v2/docs/build/plugins/overview). If non-API V1
[plugins guide](https://opencode.ai/v2/docs/build/plugins). If non-API V1
functionality fails in V2, use the `report` skill to file it as a compatibility
bug.
## [Plugins](https://opencode.ai/v2/docs/build/plugins/overview)
## [Plugins](https://opencode.ai/v2/docs/build/plugins)
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins/overview)
before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)

Some files were not shown because too many files have changed in this diff Show More