Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 6aaaac27d0 fix(llm): preserve native provider options 2026-05-23 19:33:16 -05:00
45 changed files with 2403 additions and 1454 deletions
+1 -4
View File
@@ -3,10 +3,9 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
export namespace AppFileSystem {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -40,8 +39,6 @@ export namespace AppFileSystem {
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -20,6 +20,7 @@ import {
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ProviderOptions } from "./utils/provider-options"
import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages"
@@ -136,6 +137,7 @@ const AnthropicTool = Schema.Struct({
description: Schema.String,
input_schema: JsonObject,
cache_control: Schema.optional(AnthropicCacheControl),
eager_input_streaming: Schema.optional(Schema.Boolean),
})
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
@@ -144,10 +146,10 @@ const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
const AnthropicThinking = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
})
// Anthropic accepts several `thinking` shapes (enabled with `budget_tokens`,
// adaptive with optional `display`, and disabled). The body schema permits the
// full union so explicit lowering can pick the correct fields per model.
const AnthropicThinkingBody = Schema.Record(Schema.String, Schema.Unknown)
const AnthropicBodyFields = {
model: Schema.String,
@@ -161,9 +163,12 @@ const AnthropicBodyFields = {
top_p: Schema.optional(Schema.Number),
top_k: Schema.optional(Schema.Number),
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
thinking: Schema.optional(AnthropicThinkingBody),
}
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
// Unknown provider options pass through verbatim with top-level keys snake-cased.
const AnthropicMessagesBody = Schema.StructWithRest(Schema.Struct(AnthropicBodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
const AnthropicUsage = Schema.Struct({
@@ -254,11 +259,16 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
const lowerTool = (
breakpoints: Cache.Breakpoints,
tool: ToolDefinition,
eagerInputStreaming: boolean | undefined,
): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
eager_input_streaming: eagerInputStreaming ? true : undefined,
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -413,24 +423,46 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
return messages
})
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
// Typed AI SDK Anthropic options. Mirrors the subset of
// `anthropicLanguageModelOptions` that opencode's provider transform actually
// emits today (see `packages/opencode/src/provider/transform.ts`). Unknown
// keys flow through the index signature and pass through to the wire body
// with their top-level key snake-cased.
type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"
type AnthropicThinking =
| { readonly type: "enabled"; readonly budgetTokens?: number; readonly budget_tokens?: number }
| { readonly type: "adaptive"; readonly display?: "omitted" | "summarized" }
| { readonly type: "disabled" }
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
const thinking = anthropicOptions(request)?.thinking
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
const budget =
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
: typeof thinking.budget_tokens === "number"
? thinking.budget_tokens
: undefined
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
interface AnthropicOptions {
readonly thinking?: AnthropicThinking
readonly effort?: AnthropicEffort
readonly toolStreaming?: boolean
readonly [extra: string]: unknown
}
const ANTHROPIC_KNOWN_KEYS: ReadonlySet<string> = new Set(["thinking", "effort", "toolStreaming"])
const lowerThinking = (thinking: AnthropicOptions["thinking"]) => {
if (thinking === undefined) return undefined
if (thinking.type === "disabled") return undefined
if (thinking.type === "adaptive") {
return { type: "adaptive" as const, ...(thinking.display ? { display: thinking.display } : {}) }
}
const budget = thinking.budgetTokens ?? thinking.budget_tokens
if (budget === undefined) return undefined
return { type: "enabled" as const, budget_tokens: budget }
})
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
const options = ProviderOptions.merge(request, ["anthropic"]) as AnthropicOptions
// AI SDK's `toolStreaming` controls per-tool `eager_input_streaming`. opencode
// sets `toolStreaming: false` for non-Claude models routed through
// `@ai-sdk/anthropic`; otherwise the field is left unset so the provider
// applies its own default.
const eagerInputStreaming = options.toolStreaming === true
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
@@ -438,7 +470,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const tools =
request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined
: request.tools.map((tool) => lowerTool(breakpoints, tool))
: request.tools.map((tool) => lowerTool(breakpoints, tool, eagerInputStreaming))
const system =
request.system.length === 0
? undefined
@@ -454,6 +486,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
}
return {
...ProviderOptions.passthrough(options, ANTHROPIC_KNOWN_KEYS),
model: request.model.id,
system,
messages,
@@ -465,7 +498,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: yield* lowerThinking(request),
thinking: lowerThinking(options.thinking),
...(options.effort !== undefined ? { effort: options.effort } : {}),
}
})
+42 -10
View File
@@ -15,6 +15,7 @@ import {
} from "../schema"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { ProviderOptions } from "./utils/provider-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
@@ -50,6 +51,10 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
// `reasoning_content` is a plain string per DeepSeek/OpenAI-compatible spec.
// `reasoning_details` is an OpenRouter-style array of typed reasoning objects
// (summary / encrypted / text). We accept the structured payload as-is so it
// round-trips verbatim to the provider on continuation requests.
const OpenAIChatMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }),
@@ -58,6 +63,7 @@ const OpenAIChatMessage = Schema.Union([
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Array(Schema.Record(Schema.String, Schema.Unknown))),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -79,7 +85,7 @@ export const bodyFields = {
stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
reasoning_effort: Schema.optional(Schema.String),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -88,7 +94,7 @@ export const bodyFields = {
seed: Schema.optional(Schema.Number),
stop: optionalArray(Schema.String),
}
const OpenAIChatBody = Schema.Struct(bodyFields)
const OpenAIChatBody = Schema.StructWithRest(Schema.Struct(bodyFields), [Schema.Record(Schema.String, Schema.Any)])
export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// =============================================================================
@@ -125,9 +131,16 @@ const OpenAIChatToolCallDelta = Schema.Struct({
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
// Streaming reasoning fields. `reasoning_content` (DeepSeek) and `reasoning`
// (AI SDK fallback) are strings; `reasoning_details` (OpenRouter) is an array
// of typed reasoning detail objects. We surface their plaintext via reasoning
// deltas and preserve the structured array for downstream round-trip.
const OpenAIChatReasoningDetail = Schema.Record(Schema.String, Schema.Unknown)
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(OpenAIChatReasoningDetail)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -188,6 +201,16 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
// `reasoning_details` rounds-trips the OpenRouter structured array. Accept the
// array shape as canonical; tolerate a string for legacy callers that already
// flattened it.
const openAICompatibleReasoningDetails = (native: unknown) => {
if (!isRecord(native)) return undefined
const value = native.reasoning_details
if (Array.isArray(value)) return value as ReadonlyArray<Record<string, unknown>>
return undefined
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: TextPart[] = []
for (const part of message.content) {
@@ -220,6 +243,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible),
reasoning_details: openAICompatibleReasoningDetails(message.native?.openaiCompatible),
}
})
@@ -246,13 +270,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
})
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
const options = OpenAIOptions.options(request)
const effort = options.reasoningEffort
if (effort !== undefined && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Chat does not support reasoning effort ${effort}`)
return {
...(store !== undefined ? { store } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
...(options.store !== undefined ? { store: options.store } : {}),
...(effort !== undefined ? { reasoning_effort: effort } : {}),
}
})
@@ -325,8 +350,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
// OpenRouter-style `reasoning_details` ships an array of typed reasoning
// objects (summary / text / encrypted). Concatenate the plaintext fields
// into the reasoning delta stream; the structured array is preserved on
// the assistant message for round-trip via `providerMetadata`.
const detailText = (delta?.reasoning_details ?? [])
.map((detail) => (typeof detail.text === "string" ? detail.text : typeof detail.summary === "string" ? detail.summary : ""))
.join("")
const reasoning = delta?.reasoning_content ?? delta?.reasoning ?? (detailText.length > 0 ? detailText : undefined)
if (reasoning) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning)
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
@@ -1,22 +1,84 @@
import { Effect, Schema } from "effect"
import { Route, type RouteRoutedModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import type { LLMRequest } from "../schema"
import { ProviderOptions } from "./utils/provider-options"
import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
const OpenAICompatibleChatBody = Schema.StructWithRest(
Schema.Struct({ ...OpenAIChat.bodyFields, reasoning_effort: Schema.optional(Schema.String) }),
[Schema.Record(Schema.String, Schema.Any)],
)
export type OpenAICompatibleChatBody = Schema.Schema.Type<typeof OpenAICompatibleChatBody>
// Typed AI SDK `@ai-sdk/openai-compatible` options. Known keys are lowered
// explicitly; everything else passes through to the wire body with its
// top-level key snake-cased.
interface CompatibleOptions {
readonly user?: string
readonly reasoningEffort?: string
readonly textVerbosity?: string
readonly strictJsonSchema?: boolean
readonly [extra: string]: unknown
}
const COMPATIBLE_KNOWN_KEYS: ReadonlySet<string> = new Set([
"user",
"reasoningEffort",
"textVerbosity",
"strictJsonSchema",
])
// Match AI SDK `@ai-sdk/openai-compatible` option resolution: the deprecated
// `openai-compatible` alias, the canonical `openaiCompatible` key, the raw
// provider name (dot-split so e.g. `opencode.internal` matches `opencode`),
// and its camelCase variant. Later sources override earlier ones.
const bodyOptions = (request: LLMRequest) => {
const provider = String(request.model.provider).split(".")[0]
const camel = provider.replace(/[_-]([a-z])/g, (_, value: string) => value.toUpperCase())
const options = ProviderOptions.merge(request, [
"openai-compatible",
"openaiCompatible",
provider,
camel,
]) as CompatibleOptions
return {
...ProviderOptions.passthrough(options, COMPATIBLE_KNOWN_KEYS),
...(options.user !== undefined ? { user: options.user } : {}),
...(options.reasoningEffort !== undefined ? { reasoning_effort: options.reasoningEffort } : {}),
...(options.textVerbosity !== undefined ? { verbosity: options.textVerbosity } : {}),
}
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: OpenAICompatibleChatBody,
// Drop providerOptions before delegating so OpenAI Chat's OpenAI-only
// option validation does not reject compatible-route requests whose
// provider id happens to be `openai` or use extended reasoning efforts.
from: (request) =>
OpenAIChat.protocol.body
.from({ ...request, providerOptions: undefined })
.pipe(Effect.map((body) => ({ ...body, ...bodyOptions(request) }))),
},
stream: OpenAIChat.protocol.stream,
})
/**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
* overrides only the route id so providers can be resolved per-family without
* colliding with native OpenAI. Provider helpers configure the route endpoint
* before model selection.
* `/chat/completions` endpoint. Reuses OpenAI Chat streaming behavior while
* allowing compatible providers to pass through additional request-body
* options such as `enable_thinking` and extended reasoning efforts.
*/
export const route = Route.make({
id: ADAPTER,
protocol: OpenAIChat.protocol,
protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
})
+66 -26
View File
@@ -19,6 +19,7 @@ import {
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options"
import { ProviderOptions } from "./utils/provider-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolStream } from "./utils/tool-stream"
@@ -111,12 +112,23 @@ const OpenAIResponsesCoreFields = {
tools: optionalArray(OpenAIResponsesTool),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
conversation: Schema.optional(Schema.String),
max_tool_calls: Schema.optional(Schema.Number),
metadata: Schema.optional(JsonObject),
parallel_tool_calls: Schema.optional(Schema.Boolean),
previous_response_id: Schema.optional(Schema.String),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
prompt_cache_retention: Schema.optional(Schema.String),
safety_identifier: Schema.optional(Schema.String),
service_tier: Schema.optional(Schema.String),
top_logprobs: Schema.optional(Schema.Number),
truncation: Schema.optional(Schema.String),
user: Schema.optional(Schema.String),
include: optionalArray(Schema.String),
reasoning: Schema.optional(
Schema.Struct({
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
summary: Schema.optional(Schema.Literal("auto")),
summary: Schema.optional(Schema.String),
}),
),
text: Schema.optional(
@@ -129,10 +141,15 @@ const OpenAIResponsesCoreFields = {
top_p: Schema.optional(Schema.Number),
}
const OpenAIResponsesBody = Schema.Struct({
...OpenAIResponsesCoreFields,
stream: Schema.Literal(true),
})
// Unknown provider options are passed through verbatim with their top-level
// key snake-cased; the rest record validates them against any JSON value.
const OpenAIResponsesBody = Schema.StructWithRest(
Schema.Struct({
...OpenAIResponsesCoreFields,
stream: Schema.Literal(true),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
@@ -293,14 +310,15 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
const items: ReadonlyArray<ToolResultContentPart> = part.result.value
return yield* Effect.forEach(items, lowerToolResultContentItem)
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: OpenAIResponsesInputItem[] = [...system]
const store = OpenAIOptions.store(request)
const store = OpenAIOptions.options(request).store
for (const message of request.messages) {
if (message.role === "user") {
@@ -355,25 +373,47 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
return input
})
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
const effort = OpenAIOptions.reasoningEffort(request)
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
const summary = OpenAIOptions.reasoningSummary(request)
const encryptedState = OpenAIOptions.encryptedReasoning(request)
const verbosity = OpenAIOptions.textVerbosity(request)
const instructions = OpenAIOptions.instructions(request)
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.options(request)
// OpenAI Responses does not accept the `max` reasoning effort variant.
const effort = OpenAIOptions.isReasoningEffort(options.reasoningEffort) ? options.reasoningEffort : undefined
const summary = options.reasoningSummary
const verbosity = options.textVerbosity
// `logprobs` is enabled only by `true` or a numeric top-N. `false` and
// `undefined` leave the request without the logprobs include + top_logprobs.
const logprobsEnabled = options.logprobs === true || typeof options.logprobs === "number"
const include = (() => {
const base = options.include ? [...options.include] : []
if (options.includeEncryptedReasoning && !base.includes("reasoning.encrypted_content")) {
base.push("reasoning.encrypted_content")
}
if (logprobsEnabled && !base.includes("message.output_text.logprobs")) {
base.push("message.output_text.logprobs")
}
return base.length > 0 ? base : undefined
})()
const topLogprobs = typeof options.logprobs === "number" ? options.logprobs : options.logprobs === true ? 20 : undefined
return {
...(instructions ? { instructions } : {}),
...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
...(effort || summary ? { reasoning: { effort, summary } } : {}),
...(verbosity ? { text: { verbosity } } : {}),
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.conversation !== undefined ? { conversation: options.conversation } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.previousResponseId !== undefined ? { previous_response_id: options.previousResponseId } : {}),
...(options.promptCacheKey !== undefined ? { prompt_cache_key: options.promptCacheKey } : {}),
...(options.promptCacheRetention !== undefined ? { prompt_cache_retention: options.promptCacheRetention } : {}),
...(options.safetyIdentifier !== undefined ? { safety_identifier: options.safetyIdentifier } : {}),
...(options.serviceTier !== undefined ? { service_tier: options.serviceTier } : {}),
...(topLogprobs !== undefined ? { top_logprobs: topLogprobs } : {}),
...(options.truncation !== undefined ? { truncation: options.truncation } : {}),
...(options.user !== undefined ? { user: options.user } : {}),
...(include ? { include } : {}),
...(effort !== undefined || summary !== undefined ? { reasoning: { effort, summary } } : {}),
...(verbosity !== undefined ? { text: { verbosity } } : {}),
}
})
}
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation
@@ -386,7 +426,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
...(yield* lowerOptions(request)),
...lowerOptions(request),
}
})
@@ -1,60 +1,73 @@
import { Schema } from "effect"
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
import { ReasoningEfforts, TextVerbosity } from "../../schema"
import type { LLMRequest } from "../../schema"
import { ReasoningEfforts, TextVerbosity, type ReasoningEffort } from "../../schema"
import { ProviderOptions } from "./provider-options"
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
)
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAITextVerbosity = TextVerbosity
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
const options = (request: LLMRequest) => request.providerOptions?.openai
export const store = (request: LLMRequest): boolean | undefined => {
const value = options(request)?.store
return typeof value === "boolean" ? value : undefined
// Typed AI SDK OpenAI options. Mirrors the camelCase surface AI SDK accepts.
// Known keys are typed; everything else passes through to the wire body with
// its top-level key snake-cased.
export interface Options {
readonly store?: boolean
readonly promptCacheKey?: string
readonly promptCacheRetention?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: string
readonly textVerbosity?: "low" | "medium" | "high"
readonly include?: ReadonlyArray<string>
readonly includeEncryptedReasoning?: boolean
readonly instructions?: string
readonly conversation?: string
readonly maxToolCalls?: number
readonly metadata?: Record<string, unknown>
readonly parallelToolCalls?: boolean
readonly previousResponseId?: string
readonly safetyIdentifier?: string
readonly serviceTier?: string
readonly logprobs?: boolean | number
readonly truncation?: string
readonly user?: string
readonly [extra: string]: unknown
}
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
const value = options(request)?.reasoningEffort
return isAnyReasoningEffort(value) ? value : undefined
}
export const KNOWN_KEYS: ReadonlySet<string> = new Set([
"store",
"promptCacheKey",
"promptCacheRetention",
"reasoningEffort",
"reasoningSummary",
"textVerbosity",
"include",
"includeEncryptedReasoning",
"instructions",
"conversation",
"maxToolCalls",
"metadata",
"parallelToolCalls",
"previousResponseId",
"safetyIdentifier",
"serviceTier",
"logprobs",
"truncation",
"user",
])
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
}
export const encryptedReasoning = (request: LLMRequest) =>
options(request)?.includeEncryptedReasoning === true ? true : undefined
export const promptCacheKey = (request: LLMRequest) => {
const value = options(request)?.promptCacheKey
return typeof value === "string" ? value : undefined
}
export const textVerbosity = (request: LLMRequest) => {
const value = options(request)?.textVerbosity
return isTextVerbosity(value) ? value : undefined
}
export const instructions = (request: LLMRequest) => {
const value = options(request)?.instructions
return typeof value === "string" ? value : undefined
}
// Read the merged `openai` provider option bag. Producers
// (`packages/opencode/src/provider/transform.ts`) emit typed values; we widen
// only the index signature so passthrough keys remain reachable. Invalid
// shapes surface in the lowerer where they're consumed, not at decode time.
export const options = (request: LLMRequest): Options => ProviderOptions.merge(request, ["openai"]) as Options
export * as OpenAIOptions from "./openai-options"
@@ -0,0 +1,34 @@
import type { LLMRequest } from "../../schema"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
// Convert a single top-level option key from camelCase to snake_case. Values
// are left verbatim — recursive conversion would mangle structured payloads
// (IDs, nested provider-shaped objects) and provider APIs do not require it.
// PascalCase (`FooBar`) becomes `foo_bar` without a leading underscore.
export const snakeKey = (key: string) =>
key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()
// Merge provider option namespaces using AI SDK precedence semantics: later
// sources override earlier ones, missing namespaces are skipped. Used by every
// native protocol that reads request-level provider options.
export const merge = (request: LLMRequest, keys: ReadonlyArray<string>) => {
const sources = keys.map((key) => request.providerOptions?.[key]).filter(isRecord)
return Object.assign({}, ...sources) as Record<string, unknown>
}
// Spread the unknown remainder of a merged option bag onto a provider body.
// `consumed` lists keys already lowered explicitly so they aren't duplicated
// or echoed at the wrong shape.
export const passthrough = (options: Record<string, unknown>, consumed: ReadonlySet<string>) => {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(options)) {
if (consumed.has(key)) continue
if (value === undefined) continue
result[snakeKey(key)] = value
}
return result
}
export * as ProviderOptions from "./provider-options"
@@ -209,6 +209,80 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers Anthropic thinking provider option (enabled)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "think",
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 12345 } } },
}),
)
expect(prepared.body).toMatchObject({ thinking: { type: "enabled", budget_tokens: 12345 } })
}),
)
it.effect("lowers Anthropic adaptive thinking with effort sibling", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "think",
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" } },
}),
)
expect(prepared.body).toMatchObject({
thinking: { type: "adaptive", display: "summarized" },
effort: "max",
})
}),
)
it.effect("sets per-tool eager_input_streaming only when toolStreaming is true", () =>
Effect.gen(function* () {
const off = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
prompt: "use tool",
providerOptions: { anthropic: { toolStreaming: false } },
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
}),
)
expect(off.body.tools?.[0]?.eager_input_streaming).toBeUndefined()
const on = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({
model,
prompt: "use tool",
providerOptions: { anthropic: { toolStreaming: true } },
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
}),
)
expect(on.body.tools?.[0]?.eager_input_streaming).toBe(true)
}),
)
it.effect("passes unknown Anthropic provider options through with snake-cased keys", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
prompt: "go",
providerOptions: {
anthropic: {
anthropicBeta: ["claude-2024-07-15"],
customField: { keepCamelCase: true },
},
},
}),
)
expect(prepared.body).toMatchObject({
anthropic_beta: ["claude-2024-07-15"],
custom_field: { keepCamelCase: true },
})
}),
)
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
@@ -6,7 +6,7 @@ import { Auth, LLMClient } from "../../src/route"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { dynamicResponse, fixedResponse } from "../lib/http"
import { sseEvents } from "../lib/sse"
const Json = Schema.fromJsonString(Schema.Unknown)
@@ -199,6 +199,134 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("passes through compatible options and prior reasoning for tool continuations", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
providerOptions: {
deepseek: {
reasoningEffort: "max",
textVerbosity: "low",
promptCacheKey: "session_123",
strictJsonSchema: false,
enable_thinking: true,
},
},
messages: [
Message.user("Audit the site"),
Message.make({
role: "assistant",
native: { openaiCompatible: { reasoning_content: "I should inspect the page." } },
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "page" } })],
}),
],
}),
)
expect(prepared.body).toMatchObject({
reasoning_effort: "max",
verbosity: "low",
prompt_cache_key: "session_123",
enable_thinking: true,
messages: [
{ role: "user", content: "Audit the site" },
{
role: "assistant",
reasoning_content: "I should inspect the page.",
tool_calls: [
{
id: "call_1",
function: { name: "lookup", arguments: '{"query":"page"}' },
},
],
},
],
})
expect(prepared.body).not.toHaveProperty("strictJsonSchema")
}),
)
it.effect("preserves structured reasoning_details on compatible continuations", () =>
Effect.gen(function* () {
const details = [
{ type: "reasoning.text", text: "Let me work through this.", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.encrypted", data: "sha256:abc123", format: "anthropic-claude-v1", index: 1 },
]
const prepared = yield* LLMClient.prepare(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
native: { openaiCompatible: { reasoning_details: details } },
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })],
}),
],
}),
)
expect(prepared.body).toMatchObject({
messages: [{ role: "assistant", reasoning_details: details }],
})
}),
)
it.effect("resolves dot-scoped compatible provider options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: "opencode.internal", endpoint: { baseURL: "https://api.example.test/v1" } })
.model({ id: "reasoning-model" }),
prompt: "Think.",
providerOptions: { opencode: { reasoningEffort: "max", enable_thinking: true } },
}),
)
expect(prepared.body).toMatchObject({ reasoning_effort: "max", enable_thinking: true })
}),
)
it.effect("does not apply OpenAI effort limits to compatible providers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: "openai", endpoint: { baseURL: "https://compatible.example.test/v1" } })
.model({ id: "reasoning-model" }),
prompt: "Think.",
providerOptions: { openai: { reasoningEffort: "max" } },
}),
)
expect(prepared.body).toMatchObject({ reasoning_effort: "max" })
}),
)
it.effect("parses compatible reasoning field variants", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ reasoning: "fallback" }),
deltaChunk({
reasoning_details: [
{ type: "reasoning.text", text: " text-detail", format: "anthropic-claude-v1", index: 0 },
{ type: "reasoning.summary", summary: " summary-detail", format: "anthropic-claude-v1", index: 1 },
],
}),
deltaChunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("fallback text-detail summary-detail")
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -407,6 +407,30 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes unknown OpenAI provider options through with snake-cased keys", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "passthrough",
providerOptions: {
openai: {
customCamelCaseField: "value",
already_snake_case: 42,
nested: { keepCamelCase: true },
},
},
}),
)
expect(prepared.body).toMatchObject({
custom_camel_case_field: "value",
already_snake_case: 42,
nested: { keepCamelCase: true },
})
}),
)
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+1 -1
View File
@@ -1,5 +1,5 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import {
FetchHttpClient,
HttpClient,
+1 -1
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
+1 -1
View File
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
+1 -1
View File
@@ -1,5 +1,5 @@
import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
@@ -1,5 +1,5 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
@@ -15,7 +15,6 @@ type ServiceUse<Identifier, Shape> = {
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
@@ -23,9 +22,7 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
{
get: (_, key) => {
if (typeof key !== "string") return undefined
const cached = cache.get(key)
if (cached) return cached
const accessor = (...args: unknown[]) =>
return (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
@@ -33,8 +30,6 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
cache.set(key, accessor)
return accessor
},
},
)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Context, Effect, Layer } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
type State = Record<string, string | undefined>
+1 -1
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Context, Schema } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Context, Option, Schema } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
+1 -1
View File
@@ -1,5 +1,5 @@
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
@@ -1,5 +1,5 @@
import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
+1 -1
View File
@@ -20,7 +20,7 @@ import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "project" })
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
+1 -1
View File
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "@opencode-ai/core/models-dev"
import { Auth } from "../auth"
+1 -1
View File
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow, usable } from "./overflow"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Provider } from "@/provider/provider"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream"
@@ -110,7 +110,9 @@ const messages = (input: readonly ModelMessage[]) => {
Message.make({
role: message.role,
content: content(message.content),
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
// Message provider options are already provider-native wire metadata
// (for example DeepSeek's reasoning_content continuation field).
native: isRecord(message.providerOptions) ? message.providerOptions : undefined,
}),
]
})
+1 -1
View File
@@ -1,5 +1,5 @@
import { Slug } from "@opencode-ai/core/util/slug"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import path from "path"
import { BackgroundJob } from "@/background/job"
import { BusEvent } from "@/bus/bus-event"
+1 -1
View File
@@ -1,5 +1,5 @@
import type * as SDK from "@opencode-ai/sdk/v2"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Account } from "@/account/account"
-3
View File
@@ -16,7 +16,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -244,8 +243,6 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+1 -1
View File
@@ -12,7 +12,7 @@ import { EventID } from "./schema"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EffectBridge } from "@/effect/bridge"
-3
View File
@@ -55,7 +55,6 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "tool.registry" })
@@ -82,8 +81,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<
Service,
never,
File diff suppressed because it is too large Load Diff
+75 -63
View File
@@ -3,6 +3,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { $ } from "bun"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -47,11 +48,6 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
const gitCommit = (directory: string, message: string) =>
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
@@ -76,7 +72,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("reads text file via Filesystem.readText()", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
const result = yield* read("test.txt")
expect(result.type).toBe("text")
@@ -95,7 +91,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("trims whitespace from text content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
)
const result = yield* read("test.txt")
expect(result.content).toBe("content with spaces")
@@ -105,7 +103,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles empty text file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "empty.txt", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
const result = yield* read("empty.txt")
expect(result.type).toBe("text")
@@ -116,7 +114,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles multi-line text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
)
const result = yield* read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* writeFixtureFile(test.directory, "image.png", binaryContent)
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
const result = yield* read("image.png")
expect(result.type).toBe("text")
@@ -142,7 +142,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns empty for binary non-image files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
)
const result = yield* read("binary.so")
expect(result.type).toBe("binary")
@@ -156,7 +158,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "test.json")
yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
@@ -177,7 +179,7 @@ describe("file/index Filesystem patterns", () => {
for (const testCase of testCases) {
const filepath = path.join(test.directory, `test.${testCase.ext}`)
yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
}
}),
@@ -286,7 +288,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .ts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.ts")
expect(result.type).toBe("text")
@@ -297,7 +301,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .mts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.mts")
expect(result.type).toBe("text")
@@ -308,7 +314,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .sh files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
)
const result = yield* read("test.sh")
expect(result.type).toBe("text")
@@ -319,7 +327,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats Dockerfile as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
const result = yield* read("Dockerfile")
expect(result.type).toBe("text")
@@ -330,7 +338,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns encoding info for text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", "simple text")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
const result = yield* read("test.txt")
expect(result.encoding).toBeUndefined()
@@ -341,7 +349,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns base64 encoding for images", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
)
const result = yield* read("test.jpg")
expect(result.encoding).toBe("base64")
@@ -371,10 +381,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "original\n")
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
const result = yield* status()
const entry = result.find((file) => file.path === "file.txt")
@@ -391,7 +401,9 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
)
const result = yield* status()
const entry = result.find((file) => file.path === "new.txt")
@@ -409,10 +421,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "gone.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.remove(filepath)
yield* Effect.promise(() => fs.rm(filepath))
const result = yield* status()
const entries = result.filter((file) => file.path === "gone.txt")
@@ -426,14 +438,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "keep.txt", "keep\n")
yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "initial")
yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
yield* removeFixtureFile(test.directory, "remove.txt")
yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
const result = yield* status()
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
@@ -464,15 +476,13 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "data.bin")
yield* AppFileSystem.use.writeWithDirs(
filepath,
Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
)
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add binary")
yield* AppFileSystem.use.writeWithDirs(
filepath,
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
)
const result = yield* status()
@@ -492,9 +502,11 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
yield* writeFixtureFile(test.directory, "file.txt", "content")
yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
)
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
@@ -515,10 +527,10 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
yield* writeFixtureFile(test.directory, "zz.txt", "")
yield* writeFixtureFile(test.directory, "aa.txt", "")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
const nodes = yield* list()
const dirs = nodes.filter((node) => node.type === "directory")
@@ -539,8 +551,8 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, ".DS_Store", "")
yield* writeFixtureFile(test.directory, "visible.txt", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
const names = (yield* list()).map((node) => node.name)
expect(names).not.toContain(".git")
@@ -555,10 +567,10 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
yield* writeFixtureFile(test.directory, "app.log", "log data")
yield* writeFixtureFile(test.directory, "main.ts", "code")
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
const nodes = yield* list()
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
@@ -573,9 +585,9 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
const nodes = yield* list("sub")
expect(nodes.length).toBe(2)
@@ -597,7 +609,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("works without git", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "file.txt", "hi")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
@@ -743,7 +755,7 @@ describe("file/index Filesystem patterns", () => {
yield* init()
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}),
@@ -758,10 +770,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
const result = yield* read("file.txt")
expect(result.type).toBe("text")
@@ -781,10 +793,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "staged.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "before\n")
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
yield* gitAddAll(test.directory)
const result = yield* read("staged.txt")
@@ -800,7 +812,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "clean.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
@@ -820,14 +832,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const one = yield* TestInstance
yield* writeFixtureFile(one.directory, "a.ts", "one")
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
yield* init()
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
yield* Effect.gen(function* () {
const two = yield* TestInstance
yield* writeFixtureFile(two.directory, "b.ts", "two")
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
yield* init()
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
@@ -841,14 +853,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "before.ts", "before")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
yield* init()
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
yield* Effect.promise(() => disposeAllInstances())
yield* writeFixtureFile(test.directory, "after.ts", "after")
yield* removeFixtureFile(test.directory, "before.ts")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
yield* init()
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
@@ -17,7 +17,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
},
"response": {
"status": 200,
@@ -35,7 +35,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
},
"response": {
"status": 200,
+320 -236
View File
@@ -1,16 +1,11 @@
import { beforeEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Effect } from "effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
import { requireInstance, TestInstance } from "../fixture/fixture"
import { tmpdir, withTestInstance } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server"
import * as Log from "@opencode-ai/core/util/log"
const it = testEffect(AppFileSystem.defaultLayer)
function spawnFakeServer() {
const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -21,164 +16,202 @@ function spawnFakeServer() {
}
}
const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const test = yield* TestInstance
const instance = yield* requireInstance
return yield* Effect.promise(() =>
LSPClient.create({
serverID: "fake",
server: initialization ? { ...handle, initialization } : handle,
root: test.directory,
directory: test.directory,
instance,
}),
)
})
const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const client = yield* createClient(handle, initialization)
yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore))
return client
})
const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content)
describe("LSPClient interop", () => {
beforeEach(async () => {
await Log.init({ print: true })
})
it.instance("handles workspace/workspaceFolders request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
test("handles workspace/workspaceFolders request", async () => {
const handle = spawnFakeServer() as any
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "workspace/workspaceFolders",
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "workspace/workspaceFolders",
})
it.instance("handles client/registerCapability request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "client/registerCapability",
test("handles client/registerCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "client/registerCapability",
})
it.instance("handles client/unregisterCapability request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "client/unregisterCapability",
test("handles client/unregisterCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "client/unregisterCapability",
})
it.instance("initialize does not overclaim unsupported diagnostics capabilities", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
const params = yield* Effect.promise(() =>
client.connection.sendRequest<{
capabilities: {
workspace: { diagnostics: { refreshSupport: boolean } }
textDocument: { publishDiagnostics: { versionSupport: boolean } }
}
}>("test/get-initialize-params", {}),
)
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
}),
)
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
const handle = spawnFakeServer() as any
it.instance("workspace/configuration returns one result per requested item", () =>
Effect.gen(function* () {
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
const client = yield* createScopedClient(spawnFakeServer(), initialization)
const response = yield* Effect.promise(() =>
client.connection.sendRequest<unknown[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
}),
)
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
it.instance("sends ranged didChange for incremental sync servers", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "first\n")
await client.shutdown()
})
const client = yield* createScopedClient(spawnFakeServer())
test("workspace/configuration returns one result per requested item", async () => {
const handle = spawnFakeServer() as any
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
yield* Effect.promise(() => client.notify.open({ path: file }))
yield* writeFile(file, "second\nthird\n")
yield* Effect.promise(() => client.notify.open({ path: file }))
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: {
...(handle as unknown as LSPServer.Handle),
initialization,
},
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
const change = yield* Effect.promise(() =>
client.connection.sendRequest<{
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
await client.shutdown()
})
test("sends ranged didChange for incremental sync servers", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "first\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.notify.open({ path: file })
await Bun.write(file, "second\nthird\n")
await client.notify.open({ path: file })
const change = await client.connection.sendRequest<{
textDocument: { version: number }
contentChanges: {
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
text: string
}[]
}>("test/get-last-change", {}),
)
expect(change.textDocument.version).toBe(1)
expect(change.contentChanges).toEqual([
{
range: {
start: { line: 0, character: 0 },
end: { line: 1, character: 0 },
}>("test/get-last-change", {})
expect(change.textDocument.version).toBe(1)
expect(change.contentChanges).toEqual([
{
range: {
start: { line: 0, character: 0 },
end: { line: 1, character: 0 },
},
text: "second\nthird\n",
},
text: "second\nthird\n",
},
])
}),
)
])
it.instance("document mode falls back to push diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode falls back to push diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
yield* Effect.promise(() =>
client.connection.sendNotification("test/publish-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
@@ -191,30 +224,40 @@ describe("LSPClient interop", () => {
severity: 1,
},
],
}),
)
yield* Effect.promise(() => wait)
})
await wait
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic")
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic")
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBe(0)
}),
)
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBe(0)
it.instance("document mode accepts matching push diagnostics published before waiting", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode accepts matching push diagnostics published before waiting", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() =>
client.connection.sendNotification("test/publish-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
@@ -227,31 +270,41 @@ describe("LSPClient interop", () => {
severity: 1,
},
],
}),
)
})
const diagnostic = yield* pollWithTimeout(
Effect.sync(() => client.diagnostics.get(file)?.[0]),
"push diagnostic was not published",
)
expect(diagnostic.message).toBe("push diagnostic")
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
expect(Date.now() - started).toBeLessThan(1_000)
}),
)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
it.instance("document mode waits for pull diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
const client = yield* createScopedClient(spawnFakeServer())
await client.shutdown()
},
})
})
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
test("document mode waits for pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "DocumentCompilerSemantic" }],
documentDiagnosticsByIdentifier: {
@@ -266,31 +319,41 @@ describe("LSPClient interop", () => {
},
],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "document" })
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic")
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic")
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBeGreaterThan(0)
}),
)
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBeGreaterThan(0)
it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
documentDiagnosticsByIdentifier: {
fast: [
@@ -308,34 +371,43 @@ describe("LSPClient interop", () => {
documentDelayMsByIdentifier: {
slow: 2_500,
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {}))
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
const version = await client.notify.open({ path: file })
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
await new Promise((resolve) => setTimeout(resolve, 100))
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})),
).toBeGreaterThan(1)
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
it.instance("full mode includes workspace pull diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
const related = path.join(test.directory, "other.cs")
yield* writeFile(file, "class C {}\n")
yield* writeFile(related, "class D {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("full mode includes workspace pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
const related = path.join(tmp.path, "other.cs")
await Bun.write(file, "class C {}\n")
await Bun.write(related, "class D {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [
{ identifier: "DocumentCompilerSemantic" },
@@ -370,40 +442,52 @@ describe("LSPClient interop", () => {
},
],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
}),
)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
it.instance("full mode treats an empty workspace pull response as handled", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("full mode treats an empty workspace pull response as handled", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
workspaceDiagnosticsByIdentifier: {
WorkspaceDocumentsAndProject: [],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
const version = await client.notify.open({ path: file })
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(Date.now() - started).toBeLessThan(1_000)
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
await client.shutdown()
},
})
})
})
+120 -106
View File
@@ -30,9 +30,16 @@ void Log.init({ print: false })
const encoder = new TextEncoder()
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
}
@@ -96,18 +103,10 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
}
const failureIt = (failArg: string) =>
testEffect(
Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer),
)
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
const iconDiscoveryIt = testEffect(
Layer.mergeAll(
Layer.provideMerge(
projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }),
CrossSpawnSpawner.defaultLayer,
),
AppFileSystem.defaultLayer,
),
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
)
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
@@ -126,7 +125,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -134,7 +133,7 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
}),
)
@@ -142,7 +141,7 @@ describe("Project.fromDirectory", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -154,7 +153,7 @@ describe("Project.fromDirectory", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(ProjectID.global)
}),
)
@@ -162,8 +161,8 @@ describe("Project.fromDirectory", () => {
it.live("derives stable project ID from root commit", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* Project.use.fromDirectory(tmp)
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
expect(b.id).toBe(a.id)
}),
)
@@ -173,7 +172,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}),
@@ -186,8 +185,8 @@ describe("Project.fromDirectory", () => {
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* Project.use.fromDirectory(ssh)
const { project: b } = yield* Project.use.fromDirectory(https)
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
@@ -197,7 +196,8 @@ describe("Project.fromDirectory", () => {
it.live("migrates cached root project data when origin becomes available", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: rootProject } = yield* Project.use.fromDirectory(tmp)
const projects = yield* Project.Service
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
@@ -236,7 +236,7 @@ describe("Project.fromDirectory", () => {
})
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(remoteID)
expect(
@@ -263,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => {
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp)
@@ -274,7 +274,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
@@ -284,7 +284,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
@@ -296,7 +296,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
@@ -319,7 +319,7 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(worktreePath)
expect(sandbox).toBe(worktreePath)
@@ -332,7 +332,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: main } = yield* Project.use.fromDirectory(tmp)
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() =>
@@ -345,12 +345,12 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const { project: wt } = yield* Project.use.fromDirectory(worktreePath)
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* AppFileSystem.use.existsSafe(cache)
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
expect(exists).toBe(true)
}),
)
@@ -368,8 +368,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* Project.use.fromDirectory(clone)
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
}),
@@ -400,8 +400,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
yield* Project.use.fromDirectory(worktree1)
const { project } = yield* Project.use.fromDirectory(worktree2)
yield* run((svc) => svc.fromDirectory(worktree1))
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(worktree1)
expect(project.sandboxes).toContain(worktree2)
@@ -415,9 +415,9 @@ describe("Project.discover", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* waitForProjectIcon(project.id)
expect(updated.icon?.url).toStartWith("data:")
@@ -428,12 +428,12 @@ describe("Project.discover", () => {
it.live("should discover favicon.png in root", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* Project.use.discover(project)
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -447,11 +447,11 @@ describe("Project.discover", () => {
it.live("should not discover non-image files", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image")
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
yield* Project.use.discover(project)
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -462,20 +462,22 @@ describe("Project.discover", () => {
it.live("should not discover favicon when override is set", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
yield* Project.use.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
})
yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
const updatedProject = yield* Project.use.get(project.id)
const updatedProject = yield* run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* Project.use.discover(updatedProject)
yield* run((svc) => svc.discover(updatedProject))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -489,12 +491,14 @@ describe("Project.update", () => {
it.live("should update name", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
name: "New Project Name",
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name")
@@ -506,12 +510,14 @@ describe("Project.update", () => {
it.live("should update icon url", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -523,12 +529,14 @@ describe("Project.update", () => {
it.live("should update icon color", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { color: "#ff0000" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000")
@@ -540,12 +548,14 @@ describe("Project.update", () => {
it.live("should update icon override", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
@@ -557,12 +567,14 @@ describe("Project.update", () => {
it.live("should update commands", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
commands: { start: "npm run dev" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev")
@@ -573,12 +585,12 @@ describe("Project.update", () => {
it.live("should fail when project not found", () =>
Effect.gen(function* () {
const exit = yield* Project.use
.update({
const exit = yield* run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
})
.pipe(Effect.exit)
}),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
@@ -590,7 +602,7 @@ describe("Project.update", () => {
it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
let eventPayload: any = null
const on = (data: any) => {
@@ -599,7 +611,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* Project.use.update({ projectID: project.id, name: "Updated Name" })
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
@@ -610,14 +622,16 @@ describe("Project.update", () => {
it.live("should update multiple fields at once", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -632,7 +646,7 @@ describe("Project.list and Project.get", () => {
it.live("list returns all projects", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
@@ -643,7 +657,7 @@ describe("Project.list and Project.get", () => {
it.live("get returns project by id", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const found = Project.get(project.id)
expect(found).toBeDefined()
@@ -661,7 +675,7 @@ describe("Project.setInitialized", () => {
it.live("sets time_initialized on project", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.time.initialized).toBeUndefined()
@@ -677,15 +691,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox adds directory and removeSandbox removes it", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-test")
yield* Project.use.addSandbox(project.id, sandboxDir)
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
yield* Project.use.removeSandbox(project.id, sandboxDir)
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -695,7 +709,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox emits GlobalBus event", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = []
@@ -703,7 +717,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* Project.use.addSandbox(project.id, sandboxDir)
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}),
@@ -725,7 +739,7 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* Project.use.fromDirectory(worktreePath)
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
@@ -733,8 +747,8 @@ describe("Project.fromDirectory with bare repos", () => {
const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
@@ -759,8 +773,8 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const { project: projA } = yield* Project.use.fromDirectory(worktreeA)
const { project: projB } = yield* Project.use.fromDirectory(worktreeB)
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
expect(projA.id).not.toBe(projB.id)
@@ -768,9 +782,9 @@ describe("Project.fromDirectory with bare repos", () => {
const cacheB = path.join(bareB, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
@@ -788,13 +802,13 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* Project.use.fromDirectory(worktreePath)
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
}),
)
})
@@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"
import { LLMNative } from "@/session/llm/native-request"
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ModelID, ProviderID } from "@/provider/schema"
import { OAUTH_DUMMY_KEY } from "@/auth"
import { testEffect } from "../lib/effect"
@@ -70,6 +71,21 @@ const providerInfo: Provider.Info = {
models: {},
}
const compatibleModel: Provider.Model = {
...baseModel,
id: ModelID.make("deepseek-v4-flash-free"),
providerID: ProviderID.make("opencode"),
api: {
id: "deepseek-v4-flash-free",
url: "https://ai.example.test/v1",
npm: "@ai-sdk/openai-compatible",
},
capabilities: {
...baseModel.capabilities,
interleaved: { field: "reasoning_content" },
},
}
const it = testEffect(
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
)
@@ -326,6 +342,70 @@ describe("session.llm-native.request", () => {
])
})
it.effect("preserves OpenAI-compatible reasoning continuation and provider options", () =>
Effect.gen(function* () {
const messages = ProviderTransform.message(
[
{ role: "user", content: "Audit the site" },
{
role: "assistant",
content: [
{ type: "reasoning", text: "I should inspect the page." },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "devtools_new_page",
input: { url: "https://example.test" },
},
],
},
] as ModelMessage[],
compatibleModel,
{},
)
const prepared = yield* LLMClient.prepare(
LLMNative.request({
model: compatibleModel,
apiKey: "test-key",
messages,
providerOptions: ProviderTransform.providerOptions(compatibleModel, {
reasoningEffort: "max",
textVerbosity: "low",
promptCacheKey: "session-1",
enable_thinking: true,
}),
}),
)
expect(prepared.body).toMatchObject({
model: "deepseek-v4-flash-free",
reasoning_effort: "max",
verbosity: "low",
prompt_cache_key: "session-1",
enable_thinking: true,
messages: [
{ role: "user", content: "Audit the site" },
{
role: "assistant",
content: null,
reasoning_content: "I should inspect the page.",
tool_calls: [
{
id: "call-1",
type: "function",
function: {
name: "devtools_new_page",
arguments: '{"url":"https://example.test"}',
},
},
],
},
],
})
}),
)
test("selects native request routes for provider packages", () => {
const openai = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
+308 -278
View File
@@ -8,13 +8,14 @@ import { Config } from "../../src/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { TestInstance } from "../fixture/fixture"
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import path from "path"
import fs from "fs/promises"
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node))
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
const itWithoutClaudeCodeSkills = testEffect(
Layer.mergeAll(
Skill.layer.pipe(
@@ -25,7 +26,6 @@ const itWithoutClaudeCodeSkills = testEffect(
Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })),
),
AppFileSystem.defaultLayer,
node,
),
)
@@ -39,18 +39,15 @@ const itWithoutExternalSkills = testEffect(
Layer.provide(Global.layer),
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
),
AppFileSystem.defaultLayer,
node,
),
)
const writeSkill = (dir: string, parts: string[], content: string) =>
AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content)
const createGlobalSkill = (homeDir: string) =>
writeSkill(
homeDir,
[".claude", "skills", "global-test-skill"],
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: global-test-skill
description: A global skill from ~/.claude/skills for testing.
@@ -61,6 +58,7 @@ description: A global skill from ~/.claude/skills for testing.
This skill is loaded from the global home directory.
`,
)
}
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
@@ -77,14 +75,14 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
)
describe("skill", () => {
it.instance(
"discovers skills from .opencode/skill/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeSkill(
test.directory,
[".opencode", "skill", "test-skill"],
`---
it.live("discovers skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
`---
name: test-skill
description: A test skill for verification.
---
@@ -93,111 +91,118 @@ description: A test skill for verification.
Instructions here.
`,
)
),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "test-skill")
expect(item).toBeDefined()
expect(item!.description).toBe("A test skill for verification.")
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "test-skill")
expect(item).toBeDefined()
expect(item!.description).toBe("A test skill for verification.")
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.instance(
"returns skill directories from Skill.dirs",
Effect.gen(function* () {
const test = yield* TestInstance
yield* withHome(
test.directory,
Effect.gen(function* () {
yield* writeSkill(
test.directory,
[".opencode", "skill", "dir-skill"],
`---
it.live("returns skill directories from Skill.dirs", () =>
provideTmpdirInstance(
(dir) =>
withHome(
dir,
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
`---
name: dir-skill
description: Skill for dirs test.
---
# Dir Skill
`,
)
),
)
const dirs = yield* Skill.use.dirs()
expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill"))
expect(dirs.length).toBe(1)
}),
)
}),
{ git: true },
const skill = yield* Skill.Service
const dirs = yield* skill.dirs()
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
expect(dirs.length).toBe(1)
}),
),
{ git: true },
),
)
it.instance(
"discovers multiple skills from .opencode/skill/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.all(
[
writeSkill(
test.directory,
[".opencode", "skill", "skill-one"],
`---
it.live("discovers multiple skills from .opencode/skill/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
`---
name: skill-one
description: First test skill.
---
# Skill One
`,
),
writeSkill(
test.directory,
[".opencode", "skill", "skill-two"],
`---
),
Bun.write(
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
`---
name: skill-two
description: Second test skill.
---
# Skill Two
`,
),
],
)
),
]),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
}),
{ git: true },
),
)
it.instance(
"skips skills with missing frontmatter",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeSkill(
test.directory,
[".opencode", "skill", "no-frontmatter"],
`# No Frontmatter
it.live("skips skills with missing frontmatter", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
`# No Frontmatter
Just some content without YAML frontmatter.
`,
)
),
)
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
const skill = yield* Skill.Service
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
),
)
it.instance(
"discovers skills without descriptions",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeSkill(
test.directory,
[".opencode", "skill", "manual-skill"],
`---
it.live("discovers skills without descriptions", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"),
`---
name: manual-skill
---
@@ -205,81 +210,98 @@ name: manual-skill
Instructions here.
`,
)
),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "manual-skill")
expect(item).toBeDefined()
expect(item!.description).toBeUndefined()
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "manual-skill")
expect(item).toBeDefined()
expect(item!.description).toBeUndefined()
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
}),
{ git: true },
),
)
it.instance(
"discovers skills from .claude/skills/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeSkill(
test.directory,
[".claude", "skills", "claude-skill"],
`---
it.live("discovers skills from .claude/skills/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
)
),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "claude-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.instance(
"discovers global skills from ~/.claude/skills/ directory",
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* withHome(
test.directory,
tmp.path,
Effect.gen(function* () {
yield* createGlobalSkill(test.directory)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-test-skill")
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
yield* Effect.promise(() => createGlobalSkill(tmp.path))
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-test-skill")
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}),
)
}),
{ git: true },
)
it.instance(
"returns empty array when no skills exist",
Effect.gen(function* () {
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
it.live("returns empty array when no skills exist", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
}),
{ git: true },
),
)
it.instance(
"fails with typed error when requiring a missing skill",
Effect.gen(function* () {
const error = yield* Effect.flip(Skill.use.require("missing-skill"))
expect(error).toBeInstanceOf(Skill.NotFoundError)
expect(error._tag).toBe("Skill.NotFoundError")
expect(error.name).toBe("missing-skill")
expect(error.message).toContain('Skill "missing-skill" not found.')
}),
{ git: true },
it.live("fails with typed error when requiring a missing skill", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const skill = yield* Skill.Service
const error = yield* Effect.flip(skill.require("missing-skill"))
expect(error).toBeInstanceOf(Skill.NotFoundError)
expect(error._tag).toBe("Skill.NotFoundError")
expect(error.name).toBe("missing-skill")
expect(error.message).toContain('Skill "missing-skill" not found.')
}),
{ git: true },
),
)
it.effect("exposes tagged expected skill failure classes", () =>
@@ -298,42 +320,50 @@ description: A skill in the .claude/skills directory.
}),
)
it.instance(
"discovers skills from .agents/skills/ directory",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeSkill(
test.directory,
[".agents", "skills", "agent-skill"],
`---
it.live("discovers skills from .agents/skills/ directory", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
)
),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
const item = list.find((x) => x.name === "agent-skill")
expect(item).toBeDefined()
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
}),
{ git: true },
),
)
it.instance(
"discovers global skills from ~/.agents/skills/ directory",
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* withHome(
test.directory,
tmp.path,
Effect.gen(function* () {
yield* writeSkill(
test.directory,
[".agents", "skills", "global-agent-skill"],
`---
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: global-agent-skill
description: A global skill from ~/.agents/skills for testing.
---
@@ -342,198 +372,198 @@ description: A global skill from ~/.agents/skills for testing.
This skill is loaded from the global home directory.
`,
),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-agent-skill")
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
yield* Effect.gen(function* () {
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(1)
expect(list[0].name).toBe("global-agent-skill")
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
}).pipe(provideInstance(tmp.path))
}),
)
}),
{ git: true },
)
it.instance(
"discovers skills from both .claude/skills/ and .agents/skills/",
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.all(
[
writeSkill(
test.directory,
[".claude", "skills", "claude-skill"],
`---
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
writeSkill(
test.directory,
[".agents", "skills", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
],
)
),
]),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.length).toBe(2)
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
}),
{ git: true },
),
)
itWithoutClaudeCodeSkills.instance(
"skips Claude Code skills when disabled",
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.all(
[
writeSkill(
test.directory,
[".claude", "skills", "claude-skill"],
`---
itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
writeSkill(
test.directory,
[".agents", "skills", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
],
)
),
]),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
}),
{ git: true },
),
)
itWithoutExternalSkills.instance(
"skips external skill directories when disabled",
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.all(
[
writeSkill(
test.directory,
[".claude", "skills", "claude-skill"],
`---
itWithoutExternalSkills.live("skips external skill directories when disabled", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
writeSkill(
test.directory,
[".agents", "skills", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
writeSkill(
test.directory,
[".opencode", "skill", "opencode-skill"],
`---
),
Bun.write(
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
),
],
)
),
]),
)
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
}),
{ git: true },
const skill = yield* Skill.Service
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
}),
{ git: true },
),
)
it.instance(
"properly resolves directories that skills live in",
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.all(
[
writeSkill(
test.directory,
[".claude", "skills", "claude-skill"],
`---
it.live("properly resolves directories that skills live in", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
Bun.write(
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
),
writeSkill(
test.directory,
[".agents", "skills", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
),
writeSkill(
test.directory,
[".opencode", "skill", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
),
writeSkill(
test.directory,
[".opencode", "skills", "agent-skill"],
`---
),
Bun.write(
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skills directory.
---
# OpenCode Skill
`,
),
],
)
),
]),
)
expect((yield* Skill.use.dirs()).length).toBe(4)
}),
{ git: true },
const skill = yield* Skill.Service
expect((yield* skill.dirs()).length).toBe(4)
}),
{ git: true },
),
)
})
+237 -182
View File
@@ -95,25 +95,15 @@ const brokenPluginLayer = Layer.succeed(
}),
)
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer))
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
const scout = testEffect(
Layer.mergeAll(
registryLayer({ flags: { experimentalScout: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
)
const background = testEffect(
Layer.mergeAll(
registryLayer({ flags: { experimentalBackgroundSubagents: true } }),
node,
Agent.defaultLayer,
AppFileSystem.defaultLayer,
),
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
)
const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer),
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
afterEach(async () => {
@@ -123,7 +113,8 @@ afterEach(async () => {
describe("tool.registry", () => {
it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () {
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("repo_clone")
expect(ids).not.toContain("repo_overview")
@@ -132,7 +123,8 @@ describe("tool.registry", () => {
scout.instance("shows repo research tools when experimental scout is enabled", () =>
Effect.gen(function* () {
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("repo_clone")
expect(ids).toContain("repo_overview")
@@ -141,7 +133,8 @@ describe("tool.registry", () => {
it.instance("hides task_status unless experimental background subagents are enabled", () =>
Effect.gen(function* () {
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).not.toContain("task_status")
}),
@@ -149,10 +142,11 @@ describe("tool.registry", () => {
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service
const build = yield* agent.get("build")
if (!build) throw new Error("build agent not found")
const task = (yield* ToolRegistry.use.tools({
const task = (yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: build,
@@ -165,7 +159,8 @@ describe("tool.registry", () => {
background.instance("shows task_status when experimental background subagents are enabled", () =>
Effect.gen(function* () {
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("task_status")
}),
@@ -174,20 +169,26 @@ describe("tool.registry", () => {
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tool", "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
const opencode = path.join(test.directory, ".opencode")
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
@@ -195,20 +196,25 @@ describe("tool.registry", () => {
it.instance("ignores non-tool exports in .opencode/tool files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tool", "mixed.ts"),
[
"export const helper = 'not a tool'",
"export default {",
" description: 'mixed tool',",
" args: {},",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
const tool = path.join(test.directory, ".opencode", "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "mixed.ts"),
[
"export const helper = 'not a tool'",
"export default {",
" description: 'mixed tool',",
" args: {},",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
),
)
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("mixed")
expect(ids).not.toContain("mixed_helper")
}),
@@ -223,23 +229,28 @@ describe("tool.registry", () => {
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tool", "noargs.ts"),
[
"export default {",
" description: 'tool with no args',",
" args: undefined,",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
const tool = path.join(test.directory, ".opencode", "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "noargs.ts"),
[
"export default {",
" description: 'tool with no args',",
" args: undefined,",
" execute: async () => 'ok',",
"}",
"",
].join("\n"),
),
)
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
// Built-in tools must still load — a single malformed custom tool must
// not poison the whole registry.
expect(ids).toContain("read")
const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs")
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
if (!loaded) throw new Error("noargs tool was not loaded")
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
}),
@@ -253,7 +264,8 @@ describe("tool.registry", () => {
// protection.
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
Effect.gen(function* () {
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("read")
expect(ids).toContain("broken_plugin_tool")
}),
@@ -262,20 +274,26 @@ describe("tool.registry", () => {
it.instance("loads tools from .opencode/tools (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tools", "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
@@ -283,21 +301,26 @@ describe("tool.registry", () => {
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tools", "sql.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'query database',",
" args: { query: tool.schema.string().describe('SQL query to execute') },",
" execute: async ({ query }) => query,",
"})",
"",
].join("\n"),
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "sql.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'query database',",
" args: { query: tool.schema.string().describe('SQL query to execute') },",
" execute: async ({ query }) => query,",
"})",
"",
].join("\n"),
),
)
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql")
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
if (!loaded) throw new Error("custom sql tool was not loaded")
expect(loaded?.jsonSchema).toMatchObject({
type: "object",
@@ -310,7 +333,7 @@ describe("tool.registry", () => {
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
const agents = yield* Agent.Service
const promptTools = yield* ToolRegistry.use.tools({
const promptTools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: yield* agents.defaultInfo(),
@@ -334,44 +357,53 @@ describe("tool.registry", () => {
const opencode = path.join(test.directory, ".opencode")
const customTools = path.join(opencode, "tools")
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
dereference: true,
recursive: true,
}),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(plugin, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "package.json"),
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(plugin, "dist", "index.js"),
[
"import { z } from 'zod'",
"export function tool(input) {",
" return input",
"}",
"tool.schema = z",
"",
].join("\n"),
yield* Effect.promise(() =>
Bun.write(
path.join(plugin, "dist", "index.js"),
[
"import { z } from 'zod'",
"export function tool(input) {",
" return input",
"}",
"tool.schema = z",
"",
].join("\n"),
),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(customTools, "addition.ts"),
[
'import { tool } from "@opencode-ai/plugin"',
"export default tool({",
" description: 'Use this tool to add two numbers and return their sum.',",
" args: {",
" left: tool.schema.number().describe('The first number to add'),",
" right: tool.schema.number().describe('The second number to add'),",
" },",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
"})",
"",
].join("\n"),
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "addition.ts"),
[
'import { tool } from "@opencode-ai/plugin"',
"export default tool({",
" description: 'Use this tool to add two numbers and return their sum.',",
" args: {",
" left: tool.schema.number().describe('The first number to add'),",
" right: tool.schema.number().describe('The second number to add'),",
" },",
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
"})",
"",
].join("\n"),
),
)
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition")
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
if (!loaded) throw new Error("custom addition tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
@@ -387,24 +419,29 @@ describe("tool.registry", () => {
it.instance("preserves attachments from structured custom tool results", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const customTools = path.join(test.directory, ".opencode", "tools")
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tools", "image.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'image tool',",
" args: {},",
" execute: async () => ({",
" output: 'here is an image',",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" }),",
"})",
"",
].join("\n"),
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(customTools, "image.ts"),
[
`import { tool } from ${JSON.stringify(pluginTool)}`,
"export default tool({",
" description: 'image tool',",
" args: {},",
" execute: async () => ({",
" output: 'here is an image',",
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
" }),",
"})",
"",
].join("\n"),
),
)
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image")
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
if (!loaded) throw new Error("custom image tool was not loaded")
const agents = yield* Agent.Service
const result = yield* loaded.execute({}, {
@@ -427,19 +464,24 @@ describe("tool.registry", () => {
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.writeWithDirs(
path.join(test.directory, ".opencode", "tools", "legacy.ts"),
[
"export default {",
" description: 'legacy schema tool',",
" args: { text: { type: 'string', description: 'Text to render' } },",
" execute: async ({ text }) => text,",
"}",
"",
].join("\n"),
const tools = path.join(test.directory, ".opencode", "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "legacy.ts"),
[
"export default {",
" description: 'legacy schema tool',",
" args: { text: { type: 'string', description: 'Text to render' } },",
" execute: async ({ text }) => text,",
"}",
"",
].join("\n"),
),
)
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy")
const registry = yield* ToolRegistry.Service
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
if (!loaded) throw new Error("legacy custom tool was not loaded")
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
type: "object",
@@ -456,60 +498,73 @@ describe("tool.registry", () => {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* AppFileSystem.use.writeWithDirs(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
},
},
}),
}),
),
)
const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* AppFileSystem.use.writeWithDirs(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
)
yield* AppFileSystem.use.writeWithDirs(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
),
)
const ids = yield* ToolRegistry.use.ids()
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("cowsay")
}),
)
+11 -11
View File
@@ -1,5 +1,4 @@
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
import path from "path"
@@ -8,7 +7,6 @@ import type { Permission } from "../../src/permission"
import type { Tool } from "@/tool/tool"
import { SkillTool } from "../../src/tool/skill"
import { ToolRegistry } from "@/tool/registry"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { testEffect } from "../lib/effect"
@@ -29,16 +27,17 @@ afterEach(async () => {
const node = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node, AppFileSystem.defaultLayer))
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
describe("tool.skill", () => {
it.live("execute returns skill content block with files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
yield* AppFileSystem.use.writeWithDirs(
path.join(skill, "SKILL.md"),
`---
yield* Effect.promise(() =>
Bun.write(
path.join(skill, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
@@ -47,8 +46,9 @@ description: Skill for tool tests.
Use this skill.
`,
),
)
yield* AppFileSystem.use.writeWithDirs(path.join(skill, "scripts", "demo.txt"), "demo")
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = dir
@@ -61,8 +61,8 @@ Use this skill.
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("gpt-5"),
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")
@@ -105,8 +105,8 @@ Use this skill.
const registry = yield* ToolRegistry.Service
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const tool = (yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("gpt-5"),
providerID: "opencode" as any,
modelID: "gpt-5" as any,
agent,
})).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found")