mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 07:26:47 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edf0ce766d |
@@ -1,6 +1,6 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-17
|
||||
Last reviewed: 2026-07-16
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
@@ -20,7 +20,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
|
||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
|
||||
@@ -161,18 +161,6 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
tier: "compatible",
|
||||
note: "Anthropic-compatible Messages text/tool recorded tests",
|
||||
vars: [{ name: "MINIMAX_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
|
||||
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mistral",
|
||||
label: "Mistral",
|
||||
|
||||
@@ -75,8 +75,6 @@ const OpenAIChatMessage = Schema.Union([
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
@@ -147,8 +145,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
|
||||
@@ -170,7 +166,6 @@ export interface ParserState {
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -213,12 +208,6 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
||||
return "reasoning_content"
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
@@ -259,20 +248,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
continue
|
||||
}
|
||||
}
|
||||
const text = reasoning.map((part) => part.text).join("")
|
||||
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_content:
|
||||
reasoning.length === 0
|
||||
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
: field === "reasoning_content"
|
||||
? text
|
||||
: undefined,
|
||||
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
||||
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
|
||||
reasoning.length > 0
|
||||
? reasoning.map((part) => part.text).join("")
|
||||
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -417,12 +400,6 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
})
|
||||
}
|
||||
|
||||
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
|
||||
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
|
||||
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
|
||||
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
const events: LLMEvent[] = []
|
||||
@@ -435,12 +412,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
const reasoning = reasoningDelta(delta)
|
||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||
if (reasoning)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
|
||||
openai: { reasoningField: reasoningField ?? reasoning.field },
|
||||
})
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||
@@ -477,7 +450,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
usage,
|
||||
finishReason,
|
||||
lifecycle,
|
||||
reasoningField,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -510,12 +482,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: undefined,
|
||||
}),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
|
||||
"recordedAt": "2026-07-18T03:42:22.893Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
|
||||
"recordedAt": "2026-07-18T03:42:23.876Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
|
||||
"recordedAt": "2026-07-18T03:42:25.248Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:openrouter",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "openrouter-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||
import * as Google from "../../src/providers/google"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -18,11 +17,6 @@ const anthropic = Anthropic.configure({
|
||||
})
|
||||
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
||||
const anthropicOpus = anthropic.model("claude-opus-4-7")
|
||||
const minimax = AnthropicCompatible.configure({
|
||||
apiKey: process.env.MINIMAX_API_KEY ?? "fixture",
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
provider: "minimax",
|
||||
}).model("MiniMax-M3")
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
|
||||
const gemini = google.model("gemini-2.5-flash")
|
||||
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||
@@ -114,15 +108,6 @@ describeRecordedGoldenScenarios([
|
||||
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MiniMax M3 Anthropic-compatible",
|
||||
prefix: "anthropic-compatible-messages",
|
||||
protocol: "anthropic-messages",
|
||||
model: minimax,
|
||||
requires: ["MINIMAX_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
|
||||
scenarios: ["text", "tool-call", "tool-loop"],
|
||||
},
|
||||
{
|
||||
name: "Gemini 2.5 Flash",
|
||||
prefix: "gemini",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "OpenRouter",
|
||||
model: OpenRouter.configure({
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
requires: ["OPENROUTER_API_KEY"],
|
||||
cassette: "openrouter-reasoning",
|
||||
},
|
||||
{
|
||||
name: "Vercel AI Gateway",
|
||||
model: OpenAICompatible.configure({
|
||||
provider: "vercel-ai-gateway",
|
||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
requires: ["AI_GATEWAY_API_KEY"],
|
||||
cassette: "vercel-ai-gateway-reasoning",
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const item of cases) {
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-compatible-chat",
|
||||
provider: item.model.provider,
|
||||
protocol: "openai-chat",
|
||||
requires: item.requires,
|
||||
tags: ["reasoning"],
|
||||
metadata: { model: item.model.id },
|
||||
})
|
||||
|
||||
describe(`${item.name} reasoning recorded`, () => {
|
||||
recorded.effect.with(
|
||||
"streams scalar reasoning",
|
||||
{ cassette: item.cassette },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: item.model,
|
||||
system: "Think through the arithmetic, then reply with only the final integer.",
|
||||
prompt: "What is 173 multiplied by 219?",
|
||||
generation: { maxTokens: 1536, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning" },
|
||||
})
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -540,33 +540,29 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||
for (const field of fields) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { [field]: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const body = sseEvents(
|
||||
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: field },
|
||||
})
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||
}
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { createRequire } from "node:module"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
|
||||
const command = Object.keys(packageJson.bin ?? {})[0]
|
||||
if (!command) throw new Error("OpenCode package does not declare a binary")
|
||||
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||
const sourceBinary = platform === "windows" ? `${command}.exe` : command
|
||||
const targetBinary = path.resolve(directory, packageJson.bin[command])
|
||||
const dependencies = packageJson.optionalDependencies ?? {}
|
||||
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
|
||||
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
return result.status === 0 && (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "windows") {
|
||||
const script =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
const names =
|
||||
platform === "linux"
|
||||
? isMusl()
|
||||
? arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
: [`${base}-musl`, base]
|
||||
: arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
: [base, `${base}-musl`]
|
||||
: arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline`, base]
|
||||
: [base, `${base}-baseline`]
|
||||
: [base]
|
||||
return names.filter((name) => dependencies[name])
|
||||
}
|
||||
|
||||
function copyBinary(source) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||
try {
|
||||
fs.linkSync(source, targetBinary)
|
||||
} catch {
|
||||
fs.copyFileSync(source, targetBinary)
|
||||
}
|
||||
fs.chmodSync(targetBinary, 0o755)
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packagePath = require.resolve(`${name}/package.json`)
|
||||
return path.join(path.dirname(packagePath), "bin", sourceBinary)
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return false
|
||||
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
return (
|
||||
childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
}).status === 0
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const names = packageNames()
|
||||
for (const name of names) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name))
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -32,23 +32,12 @@ async function publishDistribution(input: { root: string; name: string; binary:
|
||||
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
||||
|
||||
await $`mkdir -p ${input.root}/${input.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
|
||||
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
|
||||
[
|
||||
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
|
||||
'echo "" >&2',
|
||||
'echo "This occurs when installation scripts are disabled." >&2',
|
||||
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
|
||||
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: input.name,
|
||||
bin: { [input.binary]: `./bin/${input.binary}.exe` },
|
||||
scripts: { postinstall: "node ./postinstall.mjs" },
|
||||
bin: { [input.binary]: `./bin/${input.binary}` },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
|
||||
@@ -114,10 +114,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("plugin", {
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
@@ -46,7 +46,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
yield* run({
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
connect: (url, signal) => runServicePromise(ServerConnection.connect(url), { signal }),
|
||||
service: service
|
||||
? {
|
||||
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||
@@ -76,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
: Effect.logInfo(message, tags)
|
||||
runFork(effect)
|
||||
},
|
||||
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode-ai/core/observability"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
@@ -31,9 +32,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
auth: () => import("./commands/handlers/mcp/auth"),
|
||||
logout: () => import("./commands/handlers/mcp/logout"),
|
||||
},
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
@@ -60,7 +58,7 @@ Effect.logInfo("cli starting", {
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { loadRunProviders } from "./catalog.shared"
|
||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
||||
@@ -130,7 +130,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
|
||||
const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
const runtime = makeRuntime(Service, LayerNode.compile(node))
|
||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
|
||||
|
||||
// Fetches available variants and context limits for every provider/model pair.
|
||||
export async function resolveModelInfo(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// variant and the persisted file.
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
@@ -202,7 +203,7 @@ const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
|
||||
const runtime = makeRuntime(Service, LayerNode.compile(node, replacements))
|
||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
|
||||
return {
|
||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
@@ -26,7 +28,7 @@ export type Options = {
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(NodeServices.layer),
|
||||
)
|
||||
})
|
||||
@@ -36,15 +38,14 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
if (
|
||||
serviceOptions !== undefined &&
|
||||
port !== undefined &&
|
||||
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
|
||||
)
|
||||
return
|
||||
if (serviceOptions !== undefined) {
|
||||
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!acquired) return yield* Effect.void
|
||||
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
|
||||
}
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -52,49 +53,25 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
delete process.env.OPENCODE_PASSWORD
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
}
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const password =
|
||||
options.mode === "service"
|
||||
? config.password || randomBytes(32).toString("base64url")
|
||||
? yield* ServiceConfig.password()
|
||||
: environmentPassword
|
||||
? Redacted.value(environmentPassword)
|
||||
: randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const server = yield* start({
|
||||
hostname,
|
||||
port: Option.fromNullishOr(port),
|
||||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
||||
port: Option.fromNullishOr(options.port ?? config.port),
|
||||
password,
|
||||
instanceID,
|
||||
service:
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: {
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
}),
|
||||
},
|
||||
}).pipe(
|
||||
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
|
||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||
@@ -118,7 +95,6 @@ const register = Effect.fnUntraced(function* (
|
||||
password: string,
|
||||
id: string,
|
||||
file: string,
|
||||
shutdown: Effect.Effect<void>,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const temp = file + "." + id + ".tmp"
|
||||
@@ -131,49 +107,38 @@ const register = Effect.fnUntraced(function* (
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* publish
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.filterOrFail(owns),
|
||||
const assertRegistration = Effect.gen(function* () {
|
||||
const found = yield* current
|
||||
if (
|
||||
found !== undefined &&
|
||||
found.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
)
|
||||
return
|
||||
yield* publish
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
current.pipe(
|
||||
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
yield* assertRegistration.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.ignore,
|
||||
Effect.andThen(shutdown),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return current.pipe(
|
||||
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry(Schedule.spaced("100 millis")),
|
||||
Effect.timeoutOption("15 seconds"),
|
||||
)
|
||||
return Option.isSome(found)
|
||||
})
|
||||
|
||||
function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
function addressInUse(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
if ("code" in error && error.code === "EADDRINUSE") return true
|
||||
return "cause" in error && addressInUse(error.cause)
|
||||
}
|
||||
|
||||
function waitForStdinClose() {
|
||||
return Effect.callback<void>((resume) => {
|
||||
const close = () => resume(Effect.void)
|
||||
|
||||
@@ -21,7 +21,23 @@ export type Resolved = {
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) return { endpoint: yield* connect(args.server) } satisfies Resolved
|
||||
if (args.server !== undefined) {
|
||||
const password = yield* Env.password
|
||||
const endpoint = {
|
||||
url: args.server,
|
||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
||||
} satisfies Endpoint
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const health = yield* Effect.tryPromise({
|
||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== InstallationVersion)
|
||||
process.stderr.write(
|
||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
if (args.standalone) {
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
@@ -33,24 +49,6 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
export const connect = Effect.fn("cli.server-connection.connect")(function* (url: string) {
|
||||
const password = yield* Env.password
|
||||
const endpoint = {
|
||||
url,
|
||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
||||
} satisfies Endpoint
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const health = yield* Effect.tryPromise({
|
||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== InstallationVersion)
|
||||
process.stderr.write(
|
||||
`Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
|
||||
)
|
||||
return endpoint
|
||||
})
|
||||
|
||||
function managedService(options: EnsureOptions) {
|
||||
const reconnectOptions = { ...options, version: undefined }
|
||||
return {
|
||||
@@ -63,7 +61,10 @@ function managedService(options: EnsureOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
|
||||
const resolveManaged = Effect.fnUntraced(function* (
|
||||
options: EnsureOptions,
|
||||
mismatch: NonNullable<Args["mismatch"]>,
|
||||
) {
|
||||
if (mismatch === "replace") return yield* Service.ensure(options)
|
||||
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@ export function filename(channel = InstallationChannel) {
|
||||
return `service-${Hash.fast(channel)}.json`
|
||||
}
|
||||
|
||||
export function defaultPort(channel = InstallationChannel) {
|
||||
if (channel === "latest") return 0xc0de
|
||||
if (channel === "local") return 0xc0df
|
||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||
}
|
||||
|
||||
export function versionBelongsToChannel(
|
||||
version: string | undefined,
|
||||
channel = InstallationChannel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { randomBytes } from "node:crypto"
|
||||
@@ -53,7 +53,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
||||
pid: proc.pid,
|
||||
} satisfies Endpoint & { readonly pid: number }
|
||||
},
|
||||
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
|
||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
||||
export function start(options: Options = {}) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -18,13 +17,6 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
||||
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
@@ -98,80 +90,6 @@ test("preview registration migration never moves stable discovery", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("managed service writes its registration once", async () => {
|
||||
const service = await startManagedService("opencode-service-once-")
|
||||
try {
|
||||
const before = await fs.stat(service.registration)
|
||||
await Bun.sleep(6_000)
|
||||
const after = await fs.stat(service.registration)
|
||||
expect(after.ino).toBe(before.ino)
|
||||
expect(after.mtimeMs).toBe(before.mtimeMs)
|
||||
expect(await Bun.file(service.registration).json()).toEqual(service.info)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("deleting a managed service registration stops its owner", async () => {
|
||||
const service = await startManagedService("opencode-service-delete-")
|
||||
try {
|
||||
await fs.rm(service.registration)
|
||||
expect(await waitForExit(service.owner)).toBe(true)
|
||||
expect(await Bun.file(service.registration).exists()).toBe(false)
|
||||
await expectPortAvailable(service.port)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("deleting a failed service registration stops its owner", async () => {
|
||||
const service = await startManagedService("opencode-service-failed-delete-", true)
|
||||
try {
|
||||
await waitForFailed(service.info)
|
||||
await fs.rm(service.registration)
|
||||
expect(await waitForExit(service.owner)).toBe(true)
|
||||
await expectPortAvailable(service.port)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("corrupting a managed service registration stops its owner", async () => {
|
||||
const service = await startManagedService("opencode-service-corrupt-")
|
||||
try {
|
||||
await fs.writeFile(service.registration, "not-json")
|
||||
expect(await waitForExit(service.owner)).toBe(true)
|
||||
expect(await Bun.file(service.registration).text()).toBe("not-json")
|
||||
await expectPortAvailable(service.port)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("replacing a managed service registration stops its owner and preserves the foreign owner", async () => {
|
||||
const service = await startManagedService("opencode-service-foreign-")
|
||||
const foreign = { ...service.info, id: "foreign-owner", pid: process.pid }
|
||||
try {
|
||||
await fs.writeFile(service.registration, JSON.stringify(foreign))
|
||||
expect(await waitForExit(service.owner)).toBe(true)
|
||||
expect(await Bun.file(service.registration).json()).toEqual(foreign)
|
||||
await expectPortAvailable(service.port)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("clean managed service shutdown removes its registration", async () => {
|
||||
const service = await startManagedService("opencode-service-clean-")
|
||||
try {
|
||||
await Effect.runPromise(Service.stop({ file: service.registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
expect(await waitForExit(service.owner)).toBe(true)
|
||||
expect(await Bun.file(service.registration).exists()).toBe(false)
|
||||
} finally {
|
||||
await stopManagedService(service)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("concurrent service processes elect one server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
||||
const database = path.join(root, "opencode.db")
|
||||
@@ -212,34 +130,18 @@ test("concurrent service processes elect one server", async () => {
|
||||
)
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const port = await availablePort()
|
||||
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ port }))
|
||||
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
|
||||
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
|
||||
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const losers = processes.filter((process) => process.pid !== info.pid)
|
||||
const exited = await Promise.all(
|
||||
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
|
||||
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
|
||||
)
|
||||
|
||||
expect(exited).toEqual(losers.map(() => true))
|
||||
const errors = await Promise.all(
|
||||
losers.map(
|
||||
async (process) => (await new Response(process.stdout).text()) + (await new Response(process.stderr).text()),
|
||||
),
|
||||
)
|
||||
expect(
|
||||
losers.map((process) => process.exitCode),
|
||||
errors.filter(Boolean).join("\n"),
|
||||
).toEqual(losers.map(() => 0))
|
||||
expect(winner?.exitCode).toBe(null)
|
||||
expect(new URL(info.url).port).toBe(String(port))
|
||||
expect((await Bun.file(config).json()).password).toBe(info.password)
|
||||
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
|
||||
expect(
|
||||
await fetch(new URL("/api/health", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
@@ -249,6 +151,20 @@ test("concurrent service processes elect one server", async () => {
|
||||
version: info.version,
|
||||
pid: info.pid,
|
||||
})
|
||||
const blockedTemp = registration + "." + info.id + ".tmp"
|
||||
await fs.mkdir(blockedTemp)
|
||||
await fs.rm(registration)
|
||||
await Bun.sleep(6_000)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await fs.rm(blockedTemp, { recursive: true })
|
||||
const restored = await waitForInfo(registration)
|
||||
expect(restored.id).toBe(info.id)
|
||||
expect(restored.pid).toBe(info.pid)
|
||||
await fs.writeFile(registration, "not-json")
|
||||
const repaired = await waitForInfo(registration)
|
||||
expect(repaired.id).toBe(info.id)
|
||||
expect(repaired.pid).toBe(info.pid)
|
||||
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
const contenderExited = await Promise.race([
|
||||
@@ -256,7 +172,6 @@ test("concurrent service processes elect one server", async () => {
|
||||
Bun.sleep(10_000).then(() => false),
|
||||
])
|
||||
expect(contenderExited).toBe(true)
|
||||
expect(contender.exitCode).toBe(0)
|
||||
expect((await waitForInfo(registration)).id).toBe(info.id)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
@@ -278,200 +193,18 @@ test("concurrent service processes elect one server", async () => {
|
||||
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
await winner?.exited
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
test("configured managed service port overrides the channel default", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
|
||||
const port = await availablePort()
|
||||
const env = serviceEnv(root)
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ port, password: "" }))
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env,
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
expect(new URL(info.url).port).toBe(String(port))
|
||||
expect(info.password).not.toBe("")
|
||||
expect((await Bun.file(config).json()).password).toBe(info.password)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
await owner.exited
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||
const port = listener.port
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
try {
|
||||
expect(await contender.exited).not.toBe(0)
|
||||
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
|
||||
expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
|
||||
expect(output).toContain("opencode service set port <port>")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
listener.stop(true)
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
const requests = { count: 0 }
|
||||
using listener = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch() {
|
||||
requests.count += 1
|
||||
if (requests.count === 2) recognizing.resolve()
|
||||
return new Promise<Response>(() => {})
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(root, "config", "opencode", "service-local.json"),
|
||||
JSON.stringify({ port: listener.port }),
|
||||
)
|
||||
const stale = {
|
||||
id: "stale",
|
||||
version: InstallationVersion,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: process.pid,
|
||||
password: "stale",
|
||||
}
|
||||
await fs.writeFile(registration, JSON.stringify(stale))
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||
const exitCode = await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])
|
||||
expect(exitCode).toBe(1)
|
||||
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
|
||||
expect(output).toContain(`Managed service port ${listener.port} on 127.0.0.1 is already in use by another process`)
|
||||
expect(await Bun.file(registration).json()).toEqual(stale)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("port contender recognizes an incumbent registered during the bind race", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-race-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
const requests = { count: 0 }
|
||||
using listener = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch() {
|
||||
requests.count += 1
|
||||
if (requests.count === 2) recognizing.resolve()
|
||||
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.dirname(config), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ port: listener.port }))
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id: "stale",
|
||||
version: InstallationVersion,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: 2_147_483_647,
|
||||
password: "stale",
|
||||
}),
|
||||
)
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||
await Bun.sleep(8_000)
|
||||
const info = {
|
||||
id: "incumbent",
|
||||
version: InstallationVersion,
|
||||
url: `http://127.0.0.1:${listener.port}`,
|
||||
pid: process.pid,
|
||||
password: "incumbent",
|
||||
try {
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
await fs.writeFile(registration, JSON.stringify(info))
|
||||
|
||||
expect(await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])).toBe(0)
|
||||
expect(await Bun.file(registration).json()).toEqual(info)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 45_000)
|
||||
}, 60_000)
|
||||
|
||||
test("stale dead registration is replaced after binding the selected port", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
|
||||
const port = await availablePort()
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
|
||||
)
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
try {
|
||||
const info = await waitForInfo(registration, (value) => value.id !== "dead")
|
||||
expect(new URL(info.url).port).toBe(String(port))
|
||||
expect(info.pid).toBe(owner.pid)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
await owner.exited
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("a failed service stays registered and owns the selected port until stopped", async () => {
|
||||
test("a failed service stays registered and owns the lock until stopped", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
||||
const database = path.join(root, "database")
|
||||
await fs.mkdir(database)
|
||||
@@ -491,12 +224,10 @@ test("a failed service stays registered and owns the selected port until stopped
|
||||
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
await waitForFailed(info)
|
||||
expect(owner.exitCode).toBe(null)
|
||||
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
|
||||
expect(contender.exitCode).toBe(0)
|
||||
expect((await waitForInfo(registration)).id).toBe(info.id)
|
||||
expect(owner.exitCode).toBe(null)
|
||||
|
||||
@@ -544,86 +275,13 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
|
||||
async function waitForInfo(file: string) {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const value = await Bun.file(file)
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (value !== undefined) {
|
||||
const info = await Schema.decodeUnknownPromise(Service.Info)(value)
|
||||
if (accept(info)) return info
|
||||
}
|
||||
if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Timed out waiting for service registration")
|
||||
}
|
||||
|
||||
async function waitForFailed(info: Info) {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const status = await fetch(new URL("/api/health", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
})
|
||||
.then((response) => response.status)
|
||||
.catch(() => undefined)
|
||||
if (status === 500) return
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Timed out waiting for service boot failure")
|
||||
}
|
||||
|
||||
async function availablePort() {
|
||||
const server = Bun.serve({ port: 0, fetch: () => new Response() })
|
||||
const port = server.port
|
||||
await server.stop(true)
|
||||
if (port === undefined) throw new Error("Server did not bind a port")
|
||||
return port
|
||||
}
|
||||
|
||||
function serviceEnv(root: string) {
|
||||
return {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
}
|
||||
|
||||
async function startManagedService(prefix: string, failBoot = false) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const port = await availablePort()
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
if (failBoot) await fs.mkdir(path.join(root, "database"))
|
||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: failBoot ? { ...serviceEnv(root), OPENCODE_DB: path.join(root, "database") } : serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
const info = await waitForInfo(registration).catch(async (cause) => {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
throw cause
|
||||
})
|
||||
return { root, port, registration, owner, info }
|
||||
}
|
||||
|
||||
async function stopManagedService(service: Awaited<ReturnType<typeof startManagedService>>) {
|
||||
service.owner.kill("SIGTERM")
|
||||
await service.owner.exited
|
||||
await fs.rm(service.root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function waitForExit(process: Bun.Subprocess, timeout = 10_000) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(timeout).then(() => false)])
|
||||
}
|
||||
|
||||
async function expectPortAvailable(port: number) {
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() })
|
||||
await server.stop(true)
|
||||
}
|
||||
|
||||
@@ -29,17 +29,6 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
|
||||
return (yield* discoverLocal(options))?.endpoint
|
||||
})
|
||||
|
||||
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
|
||||
export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined || found.legacy) return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
@@ -112,15 +101,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
return Option.none<LocalService>()
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
|
||||
}),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }))
|
||||
return Option.getOrThrow(found).endpoint
|
||||
})
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
@@ -291,4 +273,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) {
|
||||
})
|
||||
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
export const Service = { discover, incumbent, ensure, stop, headers, Info }
|
||||
export const Service = { discover, ensure, stop, headers, Info }
|
||||
|
||||
@@ -2,7 +2,13 @@ import { readFile } from "node:fs/promises"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import type {
|
||||
DiscoverOptions,
|
||||
Endpoint,
|
||||
Info,
|
||||
EnsureOptions,
|
||||
StopOptions,
|
||||
} from "../service.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -32,7 +38,6 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
@@ -61,7 +66,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
@@ -124,9 +128,7 @@ function fallback() {
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
@@ -225,8 +227,7 @@ async function kill(service: LocalService, options: { readonly file?: string })
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService) {
|
||||
|
||||
@@ -108,9 +108,7 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||
|
||||
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
||||
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
||||
model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted
|
||||
from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not
|
||||
runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in
|
||||
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
|
||||
`src/openapi/types.ts` for full semantics.
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -19,7 +19,7 @@ ultimate source of truth.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
|
||||
arguments follow JSON serialization semantics before their schema applies (see the tools section).
|
||||
arguments remain subject to their schema and the outbound-handling gap listed below.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
@@ -80,7 +80,7 @@ ultimate source of truth.
|
||||
- [x] Expression and block function bodies.
|
||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
||||
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
|
||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
||||
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
||||
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
||||
@@ -157,11 +157,8 @@ ultimate source of truth.
|
||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
||||
last definition supplied for a canonical path wins.
|
||||
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
|
||||
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
|
||||
arrays densify. Tools never receive `undefined` inside their input object, though a bare `tools.t(undefined)`
|
||||
argument still reaches schema decoding as `undefined`. Program results keep the stricter
|
||||
normalization where every `undefined` becomes `null`.
|
||||
- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run;
|
||||
retain null normalization for program results and JSON serialization.
|
||||
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
|
||||
|
||||
## Objects and properties
|
||||
@@ -213,12 +210,9 @@ ultimate source of truth.
|
||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||
`repeat` still requires a finite non-negative count.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
|
||||
arguments must still be a regular expression or string pattern.
|
||||
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
|
||||
reject instead of coercing.
|
||||
- [ ] Native no-argument parity for `match()` and `search()`.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
@@ -232,16 +226,13 @@ ultimate source of truth.
|
||||
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
||||
`floor`, `ceil`, `round`, `trunc`, `sign`, `sqrt`, `cbrt`, `pow`, `hypot`, `cos`, `cosh`, `sin`, `sinh`,
|
||||
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
||||
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
|
||||
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
|
||||
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
|
||||
use their epoch time) and reject opaque runtime references as data errors.
|
||||
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||
example `Math.sumPrecise is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
|
||||
and unknown `Promise` statics keep their descriptive error.
|
||||
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
|
||||
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
|
||||
`Number(...)` directly.
|
||||
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
|
||||
during property access.
|
||||
- [ ] `Math.sumPrecise`.
|
||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||
- [ ] Global coercing `isFinite` and `isNaN`.
|
||||
|
||||
## JSON and console
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
||||
logs,
|
||||
)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), "nullify") as DataValue
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
returned = { value: result, promises }
|
||||
const warnings = yield* promises.interrupt()
|
||||
return {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PromiseNamespace,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
CodeModeDate,
|
||||
@@ -137,31 +137,21 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
|
||||
if (containsOpaqueReference(arg)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} expects argument ${index + 1} to be a data value.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
return arg
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
// Coerce arguments like native JS; opaque runtime references still reject.
|
||||
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
|
||||
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
const rejectRegex = (): void => {
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
@@ -197,11 +187,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
if (args[0] === undefined) {
|
||||
const requestedLimit = optNum(1)
|
||||
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof CodeModeRegExp) {
|
||||
@@ -216,15 +203,12 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
rejectRegex()
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
rejectRegex()
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
rejectRegex()
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
@@ -279,7 +263,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
@@ -317,8 +301,6 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
export const arrayStatics = new Set(["isArray", "of", "from"])
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
@@ -418,9 +400,11 @@ const invokeStringReplacer = <R>(
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
|
||||
if (name === "replace") value.replace(search, collect)
|
||||
else value.replaceAll(search, collect)
|
||||
if (typeof pattern !== "string") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern, collect)
|
||||
else value.replaceAll(pattern, collect)
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -105,7 +105,7 @@ export class GlobalMethodReference {
|
||||
}
|
||||
|
||||
export class CoercionFunction {
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
||||
}
|
||||
|
||||
export class UriFunction {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
type GlobalNamespaceName,
|
||||
getArray,
|
||||
getBoolean,
|
||||
getNode,
|
||||
@@ -35,7 +34,7 @@ import {
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
||||
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||
import {
|
||||
constructPromise,
|
||||
invokePromiseInstanceMethod,
|
||||
@@ -47,11 +46,10 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
|
||||
import { ScopeStack } from "./scope.js"
|
||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
||||
import { jsonStatics } from "../stdlib/json.js"
|
||||
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||
import { dateMethods } from "../stdlib/date.js"
|
||||
import { mathConstants } from "../stdlib/math.js"
|
||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
||||
import { promiseStatics } from "../stdlib/promise.js"
|
||||
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
||||
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
||||
@@ -59,7 +57,6 @@ import {
|
||||
urlMethods,
|
||||
urlProperties,
|
||||
urlSearchParamsMethods,
|
||||
urlStatics,
|
||||
urlWritableProperties,
|
||||
invokeUriFunction,
|
||||
uriArgument,
|
||||
@@ -86,32 +83,6 @@ import {
|
||||
CodeModeURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
|
||||
Object: objectStatics,
|
||||
Math: mathMethods,
|
||||
JSON: jsonStatics,
|
||||
Array: arrayStatics,
|
||||
console: consoleMethods,
|
||||
Date: dateStatics,
|
||||
URL: urlStatics,
|
||||
}
|
||||
|
||||
const calleeDescription = (callee: AstNode): string => {
|
||||
if (callee.type === "Identifier") return getString(callee, "name")
|
||||
if (callee.type === "MemberExpression") {
|
||||
const object = getNode(callee, "object")
|
||||
const property = getNode(callee, "property")
|
||||
const key =
|
||||
callee.computed !== true && property.type === "Identifier"
|
||||
? getString(property, "name")
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (object.type === "Identifier" && key !== undefined) return `${getString(object, "name")}.${key}`
|
||||
}
|
||||
return "The called value"
|
||||
}
|
||||
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
if (rhs instanceof ErrorConstructorReference) {
|
||||
const brand = errorBrandName(lhs)
|
||||
@@ -228,8 +199,6 @@ export class Interpreter<R> {
|
||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||
globalScope.set("isFinite", { mutable: false, value: new CoercionFunction("isFinite") })
|
||||
globalScope.set("isNaN", { mutable: false, value: new CoercionFunction("isNaN") })
|
||||
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||
@@ -1485,23 +1454,10 @@ export class Interpreter<R> {
|
||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
|
||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||
const operand = (current: unknown): number => {
|
||||
if (containsOpaqueReference(current)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`'${operator}' requires a data value in CodeMode.`,
|
||||
argument,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
return coerceToNumber(current)
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = getString(argument, "name")
|
||||
const current = operand(this.scopes.get(name, argument))
|
||||
const current = Number(this.scopes.get(name, argument))
|
||||
const next = current + increment
|
||||
this.scopes.set(name, next, argument)
|
||||
return prefix ? next : current
|
||||
@@ -1510,7 +1466,7 @@ export class Interpreter<R> {
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = operand(current)
|
||||
const value = Number(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
@@ -1607,9 +1563,6 @@ export class Interpreter<R> {
|
||||
callable.settle(args[0])
|
||||
return undefined
|
||||
}
|
||||
if (callable === undefined || callable === null) {
|
||||
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee).as("TypeError")
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
}
|
||||
@@ -1880,18 +1833,16 @@ export class Interpreter<R> {
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
// Unknown static members read as undefined so feature detection works like native JS.
|
||||
return new ComputedValue(undefined)
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
@@ -1907,21 +1858,12 @@ export class Interpreter<R> {
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (objectValue instanceof CoercionFunction) {
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||
}
|
||||
if (typeof key !== "string") return new ComputedValue(undefined)
|
||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) {
|
||||
return new GlobalMethodReference("Number", key)
|
||||
}
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) {
|
||||
return new GlobalMethodReference("String", key)
|
||||
}
|
||||
return new ComputedValue(undefined)
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
if (objectValue instanceof CodeModeDate) {
|
||||
|
||||
@@ -5,15 +5,11 @@ The initial adapter intentionally skips operations it cannot execute correctly.
|
||||
- Cookie parameters, authentication, and cookie-header merging.
|
||||
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
||||
- External references and complete nested `$defs` support.
|
||||
- `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection.
|
||||
- Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition.
|
||||
- Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords.
|
||||
- Projection inside `not`/`if`/`contains`, whose semantics would invert or shift if constraints were removed; those subschemas pass through unchanged, and a `$ref` from such a context to a projected `$defs` or component definition still observes hiding.
|
||||
- Iterative traversal for pathologically deep schema nesting: the directional scan and projection recurse per level and overflow the stack around ten thousand levels, below the pre-existing converter limit of roughly fifty thousand; `fromSpec` throws a catchable `RangeError` either way.
|
||||
- Relative or templated server URLs and server variables.
|
||||
- Base URLs containing query strings or fragments.
|
||||
- Runtime response-schema validation and full content negotiation.
|
||||
- Binary response values and explicit byte-oriented return types.
|
||||
- Request/response projection for `readOnly` and `writeOnly` properties.
|
||||
- SSE, WebSocket, and other streaming transports.
|
||||
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
||||
- Configurable request and response size limits.
|
||||
|
||||
@@ -3,7 +3,6 @@ import { make, type Definition } from "../tool.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
hasDirectionalSchemas,
|
||||
inputSchema,
|
||||
isRecord,
|
||||
methods,
|
||||
@@ -39,10 +38,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
const document = options.spec
|
||||
const schemes = securitySchemes(document)
|
||||
const defaultSecurity = securityRequirements(document.security)
|
||||
const requestDefinitions = componentDefinitions(document, "request")
|
||||
const responseDefinitions = hasDirectionalSchemas(document)
|
||||
? componentDefinitions(document, "response")
|
||||
: requestDefinitions
|
||||
const definitions = componentDefinitions(document)
|
||||
const paths = isRecord(document.paths) ? document.paths : {}
|
||||
const used = new Set<string>()
|
||||
const namespaces = new Set<string>()
|
||||
@@ -61,7 +57,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
summary: nonEmptyString(operationValue.summary),
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
const output = operationOutput(document, operationValue, responseDefinitions)
|
||||
const output = operationOutput(document, operationValue, definitions)
|
||||
if (!output.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: output.reason })
|
||||
continue
|
||||
@@ -106,7 +102,7 @@ export const fromSpec = (options: Options): Result => {
|
||||
segments,
|
||||
make({
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(input.fields, requestDefinitions),
|
||||
input: inputSchema(input.fields, definitions),
|
||||
output: output.value,
|
||||
run: (input) => invoke(plan, input),
|
||||
}),
|
||||
|
||||
@@ -27,225 +27,22 @@ export const nonEmptyString = (value: unknown): string | undefined =>
|
||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
const resolvePointer = (root: unknown, ref: string): unknown =>
|
||||
ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root)
|
||||
|
||||
export const resolve = (document: Document, value: unknown): unknown => {
|
||||
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
||||
if (!isRecord(current)) return current
|
||||
const ref = nonEmptyString(own(current, "$ref"))
|
||||
const ref = nonEmptyString(current.$ref)
|
||||
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
||||
const target = resolvePointer(document, ref)
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
|
||||
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
||||
}
|
||||
return next(value, new Set())
|
||||
}
|
||||
|
||||
// Model-facing directional projection: request schemas omit `readOnly` properties,
|
||||
// response schemas omit `writeOnly` properties, and `required` stays consistent.
|
||||
// Runtime values pass through unchanged.
|
||||
type SchemaDirection = "request" | "response"
|
||||
type SchemaResource = { readonly value: unknown; readonly root: unknown }
|
||||
|
||||
const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const
|
||||
|
||||
// Resolves one `$ref` hop so every link of a chain has its own sibling declarations
|
||||
// inspected; cycles terminate in the callers' cycle solver. Local `$defs`/`definitions`
|
||||
// pointers resolve against the schema being projected, other pointers rebase onto the target.
|
||||
const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => {
|
||||
if (!isRecord(resource.value)) return resource
|
||||
const ref = nonEmptyString(own(resource.value, "$ref"))
|
||||
if (ref === undefined || !ref.startsWith("#/")) return resource
|
||||
const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")
|
||||
const target = resolvePointer(local ? resource.root : document, ref)
|
||||
if (target === undefined) return resource
|
||||
return { value: target, root: local ? resource.root : target }
|
||||
}
|
||||
|
||||
// Hidden-ness and hidden names are memoized per schema object and direction so
|
||||
// diamond-shaped reference graphs stay linear. Documents are assumed immutable once
|
||||
// projected; a schema reachable under multiple resolution roots reuses the first result.
|
||||
type Solver<T> = {
|
||||
readonly values: Map<unknown, T>
|
||||
// Discovery index per schema whose strongly connected component is unresolved.
|
||||
readonly pending: Map<unknown, number>
|
||||
readonly stack: Array<unknown>
|
||||
}
|
||||
type DirectionCache = {
|
||||
readonly hidden: Solver<boolean>
|
||||
readonly names: Solver<ReadonlySet<string>>
|
||||
}
|
||||
|
||||
const emptyCache = (): DirectionCache => ({
|
||||
hidden: { values: new Map(), pending: new Map(), stack: [] },
|
||||
names: { values: new Map(), pending: new Map(), stack: [] },
|
||||
})
|
||||
|
||||
const projectionCaches = new WeakMap<Document, Record<SchemaDirection, DirectionCache>>()
|
||||
|
||||
const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => {
|
||||
const existing = projectionCaches.get(document)
|
||||
if (existing !== undefined) return existing[direction]
|
||||
const created = { request: emptyCache(), response: emptyCache() }
|
||||
projectionCaches.set(document, created)
|
||||
return created[direction]
|
||||
}
|
||||
|
||||
// Tarjan's strongly connected components: cycle members all reach the same
|
||||
// declarations, so the component root's value is final for every member. Only resolved
|
||||
// components are cached, keeping results independent of traversal order.
|
||||
type CycleScope = { lowlink: number }
|
||||
|
||||
const solveCycles = <T>(
|
||||
solver: Solver<T>,
|
||||
key: unknown,
|
||||
provisional: T,
|
||||
scope: CycleScope,
|
||||
compute: (inner: CycleScope) => T,
|
||||
): T => {
|
||||
const cached = solver.values.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
const pending = solver.pending.get(key)
|
||||
if (pending !== undefined) {
|
||||
scope.lowlink = Math.min(scope.lowlink, pending)
|
||||
return provisional
|
||||
}
|
||||
// Components pop as contiguous stack suffixes, so pending indices stay 0..size-1.
|
||||
const index = solver.pending.size
|
||||
const base = solver.stack.length
|
||||
solver.pending.set(key, index)
|
||||
solver.stack.push(key)
|
||||
const inner: CycleScope = { lowlink: Infinity }
|
||||
const value = compute(inner)
|
||||
if (inner.lowlink < index) {
|
||||
scope.lowlink = Math.min(scope.lowlink, inner.lowlink)
|
||||
return value
|
||||
}
|
||||
for (const member of solver.stack.splice(base)) {
|
||||
solver.pending.delete(member)
|
||||
solver.values.set(member, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Most documents have no directional keywords; one cached scan skips projection entirely.
|
||||
const directionalDocuments = new WeakMap<Document, boolean>()
|
||||
|
||||
export const hasDirectionalSchemas = (document: Document): boolean => {
|
||||
const cached = directionalDocuments.get(document)
|
||||
if (cached !== undefined) return cached
|
||||
const contains = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) return value.some(contains)
|
||||
if (!isRecord(value)) return false
|
||||
if (own(value, "readOnly") === true || own(value, "writeOnly") === true) return true
|
||||
return Object.values(value).some(contains)
|
||||
}
|
||||
const result = contains(document)
|
||||
directionalDocuments.set(document, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations
|
||||
// are inspected before following the reference.
|
||||
const isHidden = (
|
||||
document: Document,
|
||||
resource: SchemaResource,
|
||||
direction: SchemaDirection,
|
||||
scope: CycleScope = { lowlink: Infinity },
|
||||
): boolean => {
|
||||
const value = resource.value
|
||||
if (!isRecord(value)) return false
|
||||
if (own(value, hiddenKeyword[direction]) === true) return true
|
||||
return solveCycles(projectionCache(document, direction).hidden, value, false, scope, (inner) => {
|
||||
const target = resolveResource(document, resource)
|
||||
return (
|
||||
asArray(own(value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction, inner)) ||
|
||||
(target.value !== value && isHidden(document, target, direction, inner))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Hidden property names declared by a schema itself or inherited through `$ref` and
|
||||
// `allOf` composition, so sibling `required` lists stay consistent after projection.
|
||||
const hiddenNames = (
|
||||
document: Document,
|
||||
resource: SchemaResource,
|
||||
direction: SchemaDirection,
|
||||
scope: CycleScope = { lowlink: Infinity },
|
||||
): ReadonlySet<string> => {
|
||||
const value = resource.value
|
||||
if (!isRecord(value)) return new Set()
|
||||
return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => {
|
||||
const properties = own(value, "properties")
|
||||
const declared = isRecord(properties)
|
||||
? Object.entries(properties)
|
||||
.filter(([, property]) => isHidden(document, { ...resource, value: property }, direction))
|
||||
.map(([name]) => name)
|
||||
: []
|
||||
const composed = asArray(own(value, "allOf")).flatMap((item) => [
|
||||
...hiddenNames(document, { ...resource, value: item }, direction, inner),
|
||||
])
|
||||
const target = resolveResource(document, resource)
|
||||
const referenced = target.value === value ? [] : hiddenNames(document, target, direction, inner)
|
||||
return new Set([...declared, ...composed, ...referenced])
|
||||
})
|
||||
}
|
||||
|
||||
// `not`/`if`/`contains` subschemas pass through unprojected: they negate or select
|
||||
// rather than assert, so removing hidden properties would invert their semantics.
|
||||
const nestedSchemas = new Set([
|
||||
"items",
|
||||
"additionalProperties",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
"then",
|
||||
"else",
|
||||
])
|
||||
const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"])
|
||||
const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"])
|
||||
|
||||
const directionalSchema = (
|
||||
document: Document,
|
||||
resource: SchemaResource,
|
||||
direction: SchemaDirection,
|
||||
excluded: ReadonlySet<string> = new Set(),
|
||||
): unknown => {
|
||||
if (!isRecord(resource.value)) return resource.value
|
||||
const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)])
|
||||
const project = (item: unknown, inherited: ReadonlySet<string> = new Set()): unknown =>
|
||||
directionalSchema(document, { ...resource, value: item }, direction, inherited)
|
||||
return Object.fromEntries(
|
||||
Object.entries(resource.value).map(([key, item]) => {
|
||||
if (key === "properties" && isRecord(item)) {
|
||||
return [
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(item)
|
||||
.filter(([name]) => !hidden.has(name))
|
||||
.map(([name, property]) => [name, project(property)]),
|
||||
),
|
||||
]
|
||||
}
|
||||
if (key === "required" && Array.isArray(item)) {
|
||||
return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))]
|
||||
}
|
||||
// allOf branches share one object; hidden names apply across every branch.
|
||||
if (key === "allOf" && Array.isArray(item)) return [key, item.map((entry) => project(entry, hidden))]
|
||||
if (nestedSchemas.has(key)) return [key, project(item)]
|
||||
if (nestedSchemaLists.has(key) && Array.isArray(item)) return [key, item.map((entry) => project(entry))]
|
||||
if (nestedSchemaMaps.has(key) && isRecord(item)) {
|
||||
return [key, Object.fromEntries(Object.entries(item).map(([name, entry]) => [name, project(entry)]))]
|
||||
}
|
||||
return [key, item]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
if (!isRecord(value)) return {}
|
||||
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
||||
? fromSchemaOpenApi3_0(value)
|
||||
@@ -255,21 +52,10 @@ const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
: { ...normalized.schema, $defs: normalized.definitions }
|
||||
}
|
||||
|
||||
const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema =>
|
||||
normalizeSchema(
|
||||
document,
|
||||
hasDirectionalSchemas(document) ? directionalSchema(document, { value, root: value }, direction) : value,
|
||||
)
|
||||
|
||||
export const componentDefinitions = (
|
||||
document: Document,
|
||||
direction: SchemaDirection,
|
||||
): Readonly<Record<string, JsonSchema>> => {
|
||||
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
||||
const components = isRecord(document.components) ? document.components : {}
|
||||
const schemas = isRecord(components.schemas) ? components.schemas : {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value, direction)]),
|
||||
)
|
||||
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
|
||||
}
|
||||
|
||||
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
||||
@@ -371,7 +157,7 @@ const operationParameters = (
|
||||
if (style === "deepObject" && !explode) {
|
||||
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
||||
}
|
||||
const base = projectSchema(document, resolved.schema, "request")
|
||||
const base = projectSchema(document, resolved.schema)
|
||||
const description = nonEmptyString(resolved.description)
|
||||
unordered.push({
|
||||
name,
|
||||
@@ -405,10 +191,7 @@ const operationBody = (
|
||||
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
||||
}
|
||||
}
|
||||
const resolvedSchema = resolve(document, selected.schema)
|
||||
const schema = hasDirectionalSchemas(document)
|
||||
? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request")
|
||||
: resolvedSchema
|
||||
const schema = resolve(document, selected.schema)
|
||||
const required = resolved.required === true
|
||||
if (!isFlattenableObjectBody(schema, required)) {
|
||||
return {
|
||||
@@ -419,7 +202,7 @@ const operationBody = (
|
||||
name: "body",
|
||||
location: "body",
|
||||
required,
|
||||
schema: projectSchema(document, selected.schema, "request"),
|
||||
schema: projectSchema(document, selected.schema),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
},
|
||||
@@ -434,13 +217,11 @@ const operationBody = (
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
// Field schemas were already projected with the body as resolution root; a second
|
||||
// directional pass rooted at the field would misresolve shadowed local $defs.
|
||||
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
||||
name,
|
||||
location: "body" as const,
|
||||
required: required && requiredProperties.has(name),
|
||||
schema: normalizeSchema(document, value),
|
||||
schema: projectSchema(document, value),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
})),
|
||||
@@ -558,7 +339,7 @@ export const operationOutput = (
|
||||
continue
|
||||
}
|
||||
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
||||
outcomes.push(projectSchema(document, value.schema, "response"))
|
||||
outcomes.push(projectSchema(document, value.schema))
|
||||
}
|
||||
}
|
||||
if (outcomes.length === 0) return { ok: true, value: undefined }
|
||||
|
||||
@@ -89,7 +89,7 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
|
||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), "nullify")
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ export const dateMethods = new Set([
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
|
||||
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now":
|
||||
|
||||
@@ -2,8 +2,6 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
|
||||
import { typeofValue } from "../interpreter/references.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["parse", "stringify"])
|
||||
|
||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
@@ -18,7 +16,7 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value"), "json"), null, indent)
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
|
||||
@@ -6,8 +6,6 @@ import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
||||
|
||||
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "is", "assign", "fromEntries"])
|
||||
|
||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const input = args[0]
|
||||
|
||||
@@ -19,8 +19,6 @@ export const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
// Native parity: an undefined pattern behaves as an empty pattern.
|
||||
if (arg === undefined) return new RegExp("", extraFlags)
|
||||
if (arg instanceof CodeModeRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
|
||||
@@ -61,19 +61,10 @@ export const coerceToString = (value: unknown): string => {
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof CodeModeDate) return value.time
|
||||
if (isCodeModeValue(value)) return Number.NaN
|
||||
// Arrays coerce through our own string coercion: host Number(array) joins with host
|
||||
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
|
||||
if (Array.isArray(value)) return Number(coerceToString(value))
|
||||
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
|
||||
// other coercers match native through the undefined-argument path below.
|
||||
if (args.length === 0) {
|
||||
if (ref.name === "Number") return 0
|
||||
if (ref.name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||
@@ -81,16 +72,12 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(raw, `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
|
||||
@@ -118,7 +118,8 @@ export class ToolRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
|
||||
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> =>
|
||||
isToolDefinition<R>(value)
|
||||
|
||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||
effect.pipe(
|
||||
@@ -256,31 +257,18 @@ const copyBounded = (
|
||||
return copied
|
||||
}
|
||||
|
||||
// "json" mirrors JSON.stringify (undefined object values drop, undefined array elements become
|
||||
// null, a bare undefined passes through): use it wherever data leaves as JSON, like tool
|
||||
// arguments and stringify-style formatting. "nullify" turns every undefined, including a bare
|
||||
// one, into null: use it for program results, where the consumer must never see undefined.
|
||||
export type CopyOutMode = "json" | "nullify"
|
||||
|
||||
export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
|
||||
if (value === undefined && mode === "nullify") return null
|
||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
||||
if (value === undefined && undefinedAsNull) return null
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
|
||||
return Array.from(value, (item) => {
|
||||
const copied = copyOut(item, mode)
|
||||
return copied === undefined && mode === "json" ? null : copied
|
||||
})
|
||||
return Array.from(value, (item) => copyOut(item, undefinedAsNull))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, item]) => [key, copyOut(item, mode)] as const)
|
||||
.filter(([, item]) => !(item === undefined && mode === "json")),
|
||||
)
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
|
||||
}
|
||||
|
||||
return value
|
||||
@@ -708,13 +696,13 @@ export const make = <R>(
|
||||
invokeDefinition(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
|
||||
),
|
||||
),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = canonicalSegments(path).join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const tool = resolve(root, path)
|
||||
return yield* invokeDefinition(name, tool, externalArgs)
|
||||
}),
|
||||
|
||||
@@ -453,56 +453,6 @@ describe("CodeMode schema flexibility", () => {
|
||||
expect(observed).toStrictEqual([{ id: 42 }])
|
||||
})
|
||||
|
||||
test("outbound tool arguments follow JSON serialization semantics", async () => {
|
||||
const observed: Array<unknown> = []
|
||||
const call = Tool.make({
|
||||
description: "Observe raw input",
|
||||
input: { type: "object" },
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { adapter: { call } } })
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(
|
||||
`return await tools.adapter.call({ q: undefined, limit: 0 / 0, rate: 1 / 0, items: [1, undefined, 2], holes: [1, , 3] })`,
|
||||
),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
const received = observed[0] as Record<string, unknown>
|
||||
expect(received).toStrictEqual({ limit: null, rate: null, items: [1, null, 2], holes: [1, null, 3] })
|
||||
// The undefined-valued property is dropped like JSON.stringify, not delivered as undefined.
|
||||
expect(Object.hasOwn(received, "q")).toBe(false)
|
||||
})
|
||||
|
||||
test("dropping undefined values lets optionalKey schemas accept conditional arguments", async () => {
|
||||
const observed: Array<unknown> = []
|
||||
const find = Tool.make({
|
||||
description: "Find things",
|
||||
input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }),
|
||||
run: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return "ok"
|
||||
}),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { things: { find } } })
|
||||
|
||||
// The `cond ? value : undefined` idiom: optionalKey rejects a present undefined, so the
|
||||
// JSON boundary must drop the key before the schema decodes.
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.things.find({ query: undefined, limit: 5 })`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(observed).toStrictEqual([{ limit: 5 }])
|
||||
|
||||
const search = await Effect.runPromise(runtime.execute(`return (await search({ query: undefined })).items.length`))
|
||||
expect(search.ok).toBe(true)
|
||||
})
|
||||
|
||||
test("renders JSON Schema outputs and $defs references", async () => {
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a user",
|
||||
|
||||
@@ -59,53 +59,6 @@ const singleOperation = (operation: Record<string, unknown>, method = "get"): Do
|
||||
},
|
||||
})
|
||||
|
||||
const directionalSpec = (openapi: string): Document => ({
|
||||
openapi,
|
||||
paths: {
|
||||
"/users": {
|
||||
post: {
|
||||
operationId: "users.create",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created",
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
ReadOnlyID: { type: "string", readOnly: true },
|
||||
User: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "name", "password", "profile", "generated"],
|
||||
properties: {
|
||||
id: { type: "string", readOnly: true },
|
||||
name: { type: "string" },
|
||||
password: { type: "string", writeOnly: true },
|
||||
profile: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["createdAt", "secret", "label"],
|
||||
properties: {
|
||||
createdAt: { type: "string", readOnly: true },
|
||||
secret: { type: "string", writeOnly: true },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
generated: { $ref: "#/components/schemas/ReadOnlyID" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe("OpenAPI.fromSpec", () => {
|
||||
test("covers a representative API from generation through execution", async () => {
|
||||
const resolutions: Array<string> = []
|
||||
@@ -401,550 +354,6 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
||||
})
|
||||
|
||||
test("projects read-only and write-only properties by schema direction", () => {
|
||||
for (const version of ["3.0.3", "3.1.0"]) {
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
||||
throw new Error(`users.create was not generated for OpenAPI ${version}`)
|
||||
}
|
||||
|
||||
expect(inputTypeScript(tool)).toBe(
|
||||
"{ name: string; password: string; profile: { secret: string; label: string } }",
|
||||
)
|
||||
expect(outputTypeScript(tool)).toBe(
|
||||
"{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }",
|
||||
)
|
||||
|
||||
const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {}
|
||||
const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {}
|
||||
const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {}
|
||||
expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([
|
||||
"name",
|
||||
"password",
|
||||
"profile",
|
||||
])
|
||||
expect(requestUser.required).toEqual(["name", "password", "profile"])
|
||||
expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([
|
||||
"id",
|
||||
"name",
|
||||
"profile",
|
||||
"generated",
|
||||
])
|
||||
expect(responseUser.required).toEqual(["id", "name", "profile", "generated"])
|
||||
}
|
||||
})
|
||||
|
||||
test("projects directional annotations through local refs and allOf composition", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["local", "composed", "name"],
|
||||
properties: {
|
||||
local: { $ref: "#/$defs/ReadOnlyValue" },
|
||||
composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] },
|
||||
name: { type: "string" },
|
||||
},
|
||||
$defs: {
|
||||
ReadOnlyValue: { type: "string", readOnly: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
})
|
||||
|
||||
test("honors declarations that are siblings of a $ref", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: {
|
||||
openapi: "3.1.0",
|
||||
paths: {
|
||||
"/test": {
|
||||
post: {
|
||||
operationId: "test",
|
||||
responses: { 200: { description: "Success" } },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["record"],
|
||||
properties: {
|
||||
record: {
|
||||
$ref: "#/components/schemas/Base",
|
||||
properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } },
|
||||
required: ["extra", "note", "id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Base: {
|
||||
type: "object",
|
||||
required: ["id", "name"],
|
||||
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record = isRecord(properties.record) ? properties.record : {}
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const base = isRecord(definitions.Base) ? definitions.Base : {}
|
||||
|
||||
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"])
|
||||
expect(record.required).toEqual(["note"])
|
||||
expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"])
|
||||
expect(base.required).toEqual(["name"])
|
||||
})
|
||||
|
||||
test("honors directional declarations on intermediate reference hops", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: {
|
||||
...singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["secret", "name"],
|
||||
properties: {
|
||||
// Hidden only by the sibling declaration on the middle hop.
|
||||
secret: { $ref: "#/components/schemas/Middle" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
components: {
|
||||
schemas: {
|
||||
Middle: { $ref: "#/components/schemas/Plain", readOnly: true },
|
||||
Plain: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
})
|
||||
|
||||
test("projects cyclic component references without hanging", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: {
|
||||
openapi: "3.1.0",
|
||||
paths: {
|
||||
"/test": {
|
||||
post: {
|
||||
operationId: "test",
|
||||
responses: { 200: { description: "Success" } },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Node: {
|
||||
type: "object",
|
||||
required: ["id", "name", "child"],
|
||||
properties: {
|
||||
id: { type: "string", readOnly: true },
|
||||
name: { type: "string" },
|
||||
child: { $ref: "#/components/schemas/Node" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const node = isRecord(definitions.Node) ? definitions.Node : {}
|
||||
|
||||
expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"])
|
||||
expect(node.required).toEqual(["name", "child"])
|
||||
})
|
||||
|
||||
test("projects diamond-shaped reference graphs in linear time", () => {
|
||||
// Each component references the next twice; without memoized hidden-ness this is 2^30 work.
|
||||
const depth = 30
|
||||
const schemas = Object.fromEntries(
|
||||
Array.from({ length: depth }, (_, index) => [
|
||||
`C${index}`,
|
||||
index === depth - 1
|
||||
? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } }
|
||||
: { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] },
|
||||
]),
|
||||
)
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: {
|
||||
openapi: "3.1.0",
|
||||
paths: {
|
||||
"/test": {
|
||||
post: {
|
||||
operationId: "test",
|
||||
responses: { 200: { description: "Success" } },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { schemas },
|
||||
},
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
|
||||
|
||||
expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"])
|
||||
})
|
||||
|
||||
test("resolves hiding through reference cycles regardless of evaluation order", () => {
|
||||
// `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that
|
||||
// enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`.
|
||||
const schemas = {
|
||||
Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] },
|
||||
Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] },
|
||||
}
|
||||
const body = (properties: Record<string, unknown>) => ({
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [...Object.keys(properties), "name"],
|
||||
properties: { ...properties, name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
for (const properties of [
|
||||
{ a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } },
|
||||
{ a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } },
|
||||
]) {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } },
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps not, if, and contains subschemas unprojected", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["record"],
|
||||
properties: {
|
||||
record: {
|
||||
type: "object",
|
||||
// Removing `secret` here would turn `not` unsatisfiable and
|
||||
// flip which branch of `if` applies; both must pass through.
|
||||
not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } },
|
||||
if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
|
||||
|
||||
expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } })
|
||||
expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } })
|
||||
})
|
||||
|
||||
test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => {
|
||||
// Deliberate scope bound: alternatives may apply, so a directional declaration on
|
||||
// one alternative does not hide the property; the annotation is preserved as-is.
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["choice", "pick"],
|
||||
properties: {
|
||||
choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] },
|
||||
pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
|
||||
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
|
||||
|
||||
expect(Object.keys(properties)).toEqual(["choice", "pick"])
|
||||
expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
|
||||
expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
|
||||
})
|
||||
|
||||
test("does not misresolve shadowed local $defs when flattening body fields", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["record"],
|
||||
$defs: { Value: { type: "string" } },
|
||||
properties: {
|
||||
record: {
|
||||
type: "object",
|
||||
required: ["x"],
|
||||
properties: { x: { $ref: "#/$defs/Value" } },
|
||||
// Shadows the body-level Value; must not affect the body-rooted projection.
|
||||
$defs: { Value: { type: "string", readOnly: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const record = isRecord(properties.record) ? properties.record : {}
|
||||
|
||||
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"])
|
||||
expect(record.required).toEqual(["x"])
|
||||
})
|
||||
|
||||
test("projects directional annotations inside parameter schemas", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation({
|
||||
parameters: [
|
||||
{
|
||||
name: "filter",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["state", "id"],
|
||||
properties: { state: { type: "string" }, id: { type: "string", readOnly: true } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
|
||||
})
|
||||
|
||||
test("ignores inherited directional annotations", () => {
|
||||
const inherited: Record<string, unknown> = { type: "string" }
|
||||
Object.setPrototypeOf(inherited, { readOnly: true })
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation({
|
||||
parameters: [
|
||||
{
|
||||
name: "filter",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
// The own annotation on `id` keeps projection active for the document,
|
||||
// so `value` pins that prototype-inherited annotations are not read.
|
||||
properties: { value: inherited, id: { type: "string", readOnly: true } },
|
||||
required: ["value", "id"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||
|
||||
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
|
||||
})
|
||||
|
||||
test("cleans required properties across allOf branches", () => {
|
||||
const tool = toolAt(
|
||||
OpenAPI.fromSpec({
|
||||
baseUrl,
|
||||
spec: singleOperation(
|
||||
{
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["id", "name"],
|
||||
allOf: [
|
||||
{
|
||||
type: "object",
|
||||
required: ["id", "name"],
|
||||
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"post",
|
||||
),
|
||||
}).tools,
|
||||
"test",
|
||||
)
|
||||
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||
const body = isRecord(properties.body) ? properties.body : {}
|
||||
const allOf = Array.isArray(body.allOf) ? body.allOf : []
|
||||
const branch = isRecord(allOf[0]) ? allOf[0] : {}
|
||||
|
||||
expect(body.required).toEqual(["name"])
|
||||
expect(branch.required).toEqual(["name"])
|
||||
expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"])
|
||||
})
|
||||
|
||||
test("keeps directional schemas model-facing while preserving runtime pass-through", async () => {
|
||||
const client = recordingClient(() =>
|
||||
json({
|
||||
id: "server-id",
|
||||
name: "Ada",
|
||||
password: "returned-by-server",
|
||||
profile: { createdAt: "today", secret: "returned-secret", label: "primary" },
|
||||
generated: "generated-id",
|
||||
}),
|
||||
)
|
||||
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
|
||||
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
tool
|
||||
.run({
|
||||
id: "ignored-top-level",
|
||||
generated: "ignored-generated",
|
||||
name: "Ada",
|
||||
password: "request-secret",
|
||||
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
|
||||
})
|
||||
.pipe(Effect.provide(client.layer)),
|
||||
)
|
||||
|
||||
expect(client.requests[0]?.body).toEqual({
|
||||
name: "Ada",
|
||||
password: "request-secret",
|
||||
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
|
||||
})
|
||||
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
|
||||
})
|
||||
|
||||
test("documents that the opencode fixture is unauthenticated", async () => {
|
||||
const spec = await opencodeSpec()
|
||||
const components = isRecord(spec.components) ? spec.components : {}
|
||||
@@ -1116,9 +525,9 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(client.requests[0]?.url).toBe(
|
||||
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
||||
)
|
||||
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||
"Parameter 'tags' contains an unsupported nested value.",
|
||||
)
|
||||
await expect(
|
||||
Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.")
|
||||
await expect(
|
||||
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
||||
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
||||
|
||||
@@ -258,23 +258,11 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
|
||||
|
||||
test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => {
|
||||
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
|
||||
expect(ToolRuntime.copyOut(NaN, "json")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(Infinity, "json")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(-Infinity, "nullify")).toBeNull()
|
||||
expect(ToolRuntime.copyOut(42, "json")).toBe(42)
|
||||
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] }, "json")).toEqual({ a: null, b: [null, 1] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("copyOut undefined handling per boundary mode", () => {
|
||||
test("json mode mirrors JSON.stringify for undefined", () => {
|
||||
expect(ToolRuntime.copyOut({ q: undefined, keep: 1 }, "json")).toStrictEqual({ keep: 1 })
|
||||
expect(ToolRuntime.copyOut([1, undefined, 2], "json")).toStrictEqual([1, null, 2])
|
||||
expect(ToolRuntime.copyOut({ nested: { a: undefined, b: [undefined] } }, "json")).toStrictEqual({
|
||||
nested: { b: [null] },
|
||||
})
|
||||
expect(ToolRuntime.copyOut(undefined, "json")).toBeUndefined()
|
||||
expect(ToolRuntime.copyOut({ a: undefined }, "nullify")).toStrictEqual({ a: null })
|
||||
expect(ToolRuntime.copyOut(NaN)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
|
||||
expect(ToolRuntime.copyOut(42)).toBe(42)
|
||||
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -681,177 +669,3 @@ describe("destructuring assignment", () => {
|
||||
expect(err.message).toContain("Property key must be a string or number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: zero-argument coercion functions", () => {
|
||||
test("Number() is 0 and String() is empty, unlike their undefined-argument forms", async () => {
|
||||
expect(await value(`return Number()`)).toBe(0)
|
||||
expect(await value(`return String()`)).toBe("")
|
||||
expect(await value(`return Boolean()`)).toBe(false)
|
||||
expect(await value(`return Number.isNaN(Number(undefined))`)).toBe(true)
|
||||
expect(await value(`return String(undefined)`)).toBe("undefined")
|
||||
})
|
||||
|
||||
test("parseInt() and parseFloat() stay NaN with no argument", async () => {
|
||||
expect(await value(`return Number.isNaN(parseInt())`)).toBe(true)
|
||||
expect(await value(`return Number.isNaN(parseFloat())`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: global isFinite and isNaN", () => {
|
||||
test("coerce their argument like native JS, unlike the Number statics", async () => {
|
||||
expect(await value(`return isFinite("42")`)).toBe(true)
|
||||
expect(await value(`return Number.isFinite("42")`)).toBe(false)
|
||||
expect(await value(`return isNaN("oops")`)).toBe(true)
|
||||
expect(await value(`return isNaN("42")`)).toBe(false)
|
||||
expect(await value(`return isFinite(Infinity)`)).toBe(false)
|
||||
expect(await value(`return isNaN(null)`)).toBe(false)
|
||||
})
|
||||
|
||||
test("zero-argument forms match native", async () => {
|
||||
expect(await value(`return isFinite()`)).toBe(false)
|
||||
expect(await value(`return isNaN()`)).toBe(true)
|
||||
})
|
||||
|
||||
test("read as functions", async () => {
|
||||
expect(await value(`return typeof isFinite`)).toBe("function")
|
||||
expect(await value(`return typeof isNaN`)).toBe("function")
|
||||
})
|
||||
|
||||
test("work as array callbacks", async () => {
|
||||
expect(await value(`return [1, "2", "x", Infinity].filter(isFinite)`)).toEqual([1, "2"])
|
||||
expect(await value(`return ["1", "x"].map(isNaN)`)).toEqual([false, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: arrays coerce to numbers through their string form", () => {
|
||||
test("arrays with objects become NaN instead of crashing on host ToPrimitive", async () => {
|
||||
expect(await value(`let x = [{}]; x++; return Number.isNaN(x)`)).toBe(true)
|
||||
expect(await value(`return isFinite([{}])`)).toBe(false)
|
||||
expect(await value(`return "abc".slice([{}])`)).toBe("abc")
|
||||
})
|
||||
|
||||
test("single-element and empty arrays match native Number()", async () => {
|
||||
expect(await value(`return Number([5])`)).toBe(5)
|
||||
expect(await value(`return Number([])`)).toBe(0)
|
||||
expect(await value(`return Number.isNaN(Number([1, 2]))`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: String method arguments coerce like native JS", () => {
|
||||
test("includes and indexOf coerce numbers", async () => {
|
||||
expect(await value(`return "v1.2".includes(1)`)).toBe(true)
|
||||
expect(await value(`return "a2b".indexOf(2)`)).toBe(1)
|
||||
expect(await value(`return "abc".includes("d")`)).toBe(false)
|
||||
})
|
||||
|
||||
test("slice, repeat, and padStart coerce numeric strings", async () => {
|
||||
expect(await value(`return "abc".slice("1")`)).toBe("bc")
|
||||
expect(await value(`return "ab".repeat("2")`)).toBe("abab")
|
||||
expect(await value(`return "7".padStart("3", 0)`)).toBe("007")
|
||||
})
|
||||
|
||||
test("split coerces separators but treats undefined as absent", async () => {
|
||||
expect(await value(`return "a1b".split(1)`)).toEqual(["a", "b"])
|
||||
expect(await value(`return "a,b".split(undefined)`)).toEqual(["a,b"])
|
||||
expect(await value(`return "a,b".split()`)).toEqual(["a,b"])
|
||||
expect(await value(`return "a,b".split(undefined, 0)`)).toEqual([])
|
||||
expect(await value(`return "a,b".split(undefined, 1)`)).toEqual(["a,b"])
|
||||
})
|
||||
|
||||
test("replace coerces search and replacement values", async () => {
|
||||
expect(await value(`return "a1b".replace(1, 2)`)).toBe("a2b")
|
||||
expect(await value(`return "a1b".replace(1, () => "x")`)).toBe("axb")
|
||||
})
|
||||
|
||||
test("repeat rejections carry the native RangeError name", async () => {
|
||||
expect(await value(`try { "a".repeat(-1) } catch (e) { return e.name }`)).toBe("RangeError")
|
||||
})
|
||||
|
||||
test("includes, startsWith, and endsWith reject regular expressions with a TypeError", async () => {
|
||||
expect(await value(`try { "abc".includes(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
expect(await value(`try { "abc".startsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
expect(await value(`try { "abc".endsWith(/a/) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
})
|
||||
|
||||
test("opaque runtime references still reject as data errors", async () => {
|
||||
const err = await error(`const f = () => 1; return "abc".includes(f)`)
|
||||
expect(err.message).toContain("data value")
|
||||
const replacerErr = await error(`const f = () => 1; return "a".replace(f, () => "x")`)
|
||||
expect(replacerErr.message).toContain("data value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: match() and search() with no argument", () => {
|
||||
test("behave as an empty pattern like native JS", async () => {
|
||||
expect(await value(`return "abc".search()`)).toBe(0)
|
||||
expect(await value(`const m = "abc".match(); return { first: m[0], index: m.index }`)).toEqual({
|
||||
first: "",
|
||||
index: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: ++ and -- use CodeMode numeric coercion", () => {
|
||||
test("numeric strings increment like native JS", async () => {
|
||||
expect(await value(`let x = "5"; x++; return x`)).toBe(6)
|
||||
expect(await value(`let x = "5"; return ++x`)).toBe(6)
|
||||
expect(await value(`const o = { n: "2" }; o.n--; return o.n`)).toBe(1)
|
||||
})
|
||||
|
||||
test("dates increment through their epoch time", async () => {
|
||||
expect(await value(`let d = new Date(5); d++; return d`)).toBe(6)
|
||||
})
|
||||
|
||||
test("plain data objects become NaN instead of crashing", async () => {
|
||||
expect(await value(`let x = {}; x++; return Number.isNaN(x)`)).toBe(true)
|
||||
expect(await value(`const o = { a: {} }; o.a++; return Number.isNaN(o.a)`)).toBe(true)
|
||||
})
|
||||
|
||||
test("opaque runtime references reject with a clear error", async () => {
|
||||
const err = await error(`let f = () => 1; f++`)
|
||||
expect(err.message).toContain("data value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("coercion parity: unknown static members read as undefined", () => {
|
||||
test("feature detection on missing statics works like native JS", async () => {
|
||||
expect(await value(`return typeof Math.sumPrecise`)).toBe("undefined")
|
||||
expect(await value(`return Object.groupBy === undefined`)).toBe(true)
|
||||
expect(await value(`return RegExp.escape === undefined`)).toBe(true)
|
||||
expect(await value(`return Number.range === undefined`)).toBe(true)
|
||||
expect(await value(`return String.raw === undefined`)).toBe(true)
|
||||
expect(await value(`return isFinite.something === undefined`)).toBe(true)
|
||||
expect(await value(`return console.group === undefined`)).toBe(true)
|
||||
expect(await value(`return Date.moment === undefined`)).toBe(true)
|
||||
expect(await value(`return JSON.rawJSON === undefined`)).toBe(true)
|
||||
expect(await value(`return URL.createObjectURL === undefined`)).toBe(true)
|
||||
expect(await value(`return Map.groupBy === undefined`)).toBe(true)
|
||||
expect(await value(`return Math.sumPrecise?.([1]) ?? "fallback"`)).toBe("fallback")
|
||||
})
|
||||
|
||||
test("known statics still resolve and run", async () => {
|
||||
expect(await value(`return typeof Math.max`)).toBe("function")
|
||||
expect(await value(`return typeof console.log`)).toBe("function")
|
||||
expect(await value(`return typeof Date.now`)).toBe("function")
|
||||
expect(await value(`return Math.max(1, 2)`)).toBe(2)
|
||||
expect(await value(`return URL.canParse("https://example.com")`)).toBe(true)
|
||||
expect(await value(`return Number.isInteger(3)`)).toBe(true)
|
||||
expect(await value(`return Number.MAX_SAFE_INTEGER`)).toBe(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
test("calling an unknown static reports a native-style TypeError", async () => {
|
||||
expect(await value(`try { Math.sumPrecise([1]) } catch (e) { return e.name + ": " + e.message }`)).toBe(
|
||||
"TypeError: Math.sumPrecise is not a function.",
|
||||
)
|
||||
expect(await value(`try { Math["sumPrecise"]([1]) } catch (e) { return e.message }`)).toBe(
|
||||
"Math.sumPrecise is not a function.",
|
||||
)
|
||||
})
|
||||
|
||||
test("blocked members still throw instead of reading as undefined", async () => {
|
||||
const err = await error(`return Math.constructor`)
|
||||
expect(err.message).toContain("not available")
|
||||
const coercionErr = await error(`return Number.constructor`)
|
||||
expect(coercionErr.message).toContain("Number.constructor is not available")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
import { Global } from "../../global"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { SHELL_OUTPUT_GLOB } from "../../permission/defaults"
|
||||
import type { LocationMutation } from "../../location-mutation"
|
||||
import type { ReadTool } from "../../tool/read"
|
||||
import type { EditTool } from "../../tool/edit"
|
||||
@@ -112,6 +113,23 @@ export const Plugin = define({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Internal shell output remains readable through broad external-directory denials.
|
||||
// An exact deny still lets users explicitly revoke access to these files.
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => {
|
||||
const denied = agent.permissions.some(
|
||||
(rule) =>
|
||||
rule.action === "external_directory" && rule.resource === SHELL_OUTPUT_GLOB && rule.effect === "deny",
|
||||
)
|
||||
if (
|
||||
denied ||
|
||||
PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, agent.permissions).effect === "allow"
|
||||
)
|
||||
return
|
||||
agent.permissions.push({ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "allow" })
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { allowExternalDirectories } from "../../permission/defaults"
|
||||
import { Repository } from "../../repository"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.reference",
|
||||
@@ -18,35 +20,20 @@ export const Plugin = define({
|
||||
const global = yield* Global.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const [name, source] of sources(loaded.entries, location.directory, global.home)) draft.add(name, source)
|
||||
})
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const permissions = allowExternalDirectories(
|
||||
Array.from(sources(loaded.entries, location.directory, global.home)).flatMap(([, source]) => {
|
||||
if (source.type === "local") return [path.join(source.path, "*")]
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) return []
|
||||
return [path.join(Repository.cachePath(global.repos, repository), "*")]
|
||||
}),
|
||||
)
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
@@ -54,6 +41,7 @@ export const Plugin = define({
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.reference.reload()),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
@@ -61,6 +49,36 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function sources(entries: readonly Config.Entry[], location: string, home: string) {
|
||||
const result = new Map<string, Reference.Source>()
|
||||
for (const doc of entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
result.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(localPath(directory, home, typeof entry === "string" ? entry : entry.path)),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function validAlias(name: string) {
|
||||
return name.length > 0 && !/[\/\s`,]/.test(name)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { SkillDiscovery } from "../../skill/discovery"
|
||||
import { allowExternalDirectories } from "../../permission/defaults"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.skill",
|
||||
@@ -17,41 +19,19 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const claude = loaded.entries.flatMap((entry) => (entry.type === "claude" ? [entry.path] : []))
|
||||
const agents = loaded.entries.flatMap((entry) => (entry.type === "agents" ? [entry.path] : []))
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
for (const source of sources(loaded.entries, global.home, location.directory)) draft.source(source)
|
||||
})
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const permissions = allowExternalDirectories(
|
||||
sources(loaded.entries, global.home, location.directory).map((source) =>
|
||||
path.join(
|
||||
source.type === "directory" ? source.path : SkillDiscovery.cachePath(global.cache, source.url),
|
||||
"*",
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
@@ -60,9 +40,44 @@ export const Plugin = define({
|
||||
config.entries().pipe(
|
||||
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
|
||||
Effect.andThen(ctx.skill.reload()),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function sources(entries: readonly Config.Entry[], home: string, directory: string) {
|
||||
const result: Array<SkillV2.DirectorySource | SkillV2.UrlSource> = []
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "claude" || entry.type === "agents") {
|
||||
result.push(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (entry.type === "directory") {
|
||||
result.push(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skill")) }),
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(entry.path, "skills")) }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (entry.type !== "document") continue
|
||||
for (const item of entry.info.skills ?? []) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
result.push(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(home, item.slice(2)) : item
|
||||
result.push(
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ export type Inputs = Integration.Inputs
|
||||
export type OAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly expiresAt?: number
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
@@ -561,7 +560,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
const id = AttemptID.create()
|
||||
const created = yield* Clock.currentTimeMillis
|
||||
const time = { created, expires: authorization.expiresAt ?? created + attemptLifetime }
|
||||
const time = { created, expires: created + attemptLifetime }
|
||||
yield* SynchronizedRef.update(attempts, (current) =>
|
||||
new Map(current).set(id, {
|
||||
status: "pending",
|
||||
|
||||
+70
-157
@@ -13,10 +13,8 @@ import { EventV2 } from "../event"
|
||||
import { Form } from "../form"
|
||||
import { Integration } from "../integration"
|
||||
import { IntegrationConnection } from "../integration/connection"
|
||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||
import { Location } from "../location"
|
||||
import { waitForAbort } from "../process"
|
||||
import { State } from "../state"
|
||||
import { MCPClient } from "./client"
|
||||
import { MCPOAuth } from "./oauth"
|
||||
|
||||
@@ -120,7 +118,6 @@ type ServerEntry = {
|
||||
prompts?: ReadonlyArray<Prompt>
|
||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||
integrationID?: Integration.ID
|
||||
registration?: State.Registration
|
||||
}
|
||||
|
||||
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
||||
@@ -130,10 +127,6 @@ const URL_ELICITATION_FIELD_KEY = "elicitation"
|
||||
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
|
||||
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
readonly tools: () => Effect.Effect<Tool[]>
|
||||
readonly callTool: (input: {
|
||||
readonly server: ServerName | string
|
||||
@@ -177,9 +170,6 @@ export const layer = Layer.effect(
|
||||
)
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
// Serializes lifecycle operations per server. Anything taking this lock from a connection
|
||||
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
|
||||
const locks = KeyedMutex.makeUnsafe<ServerName>()
|
||||
const urlElicitations = new Map<string, Form.ID>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
@@ -193,9 +183,14 @@ export const layer = Layer.effect(
|
||||
|
||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||
const owned = new Set<Integration.ID>()
|
||||
const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || entry.config.oauth === false) return
|
||||
const registrations: Array<{
|
||||
readonly name: ServerName
|
||||
readonly remote: typeof ConfigMCP.Remote.Type
|
||||
readonly integrationID: Integration.ID
|
||||
readonly methodID: Integration.MethodID
|
||||
}> = []
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
|
||||
const remote = entry.config
|
||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||
@@ -205,24 +200,27 @@ export const layer = Layer.effect(
|
||||
.update(name + "\u0000" + remote.url)
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
const integrationID = Integration.ID.make(suffix)
|
||||
entry.integrationID = integrationID
|
||||
owned.add(integrationID)
|
||||
const methodID = Integration.MethodID.make(suffix)
|
||||
entry.registration = yield* integration
|
||||
.transform((draft) => {
|
||||
draft.update(integrationID, (ref) => {
|
||||
ref.name = name
|
||||
entry.integrationID = Integration.ID.make(suffix)
|
||||
registrations.push({
|
||||
name,
|
||||
remote,
|
||||
integrationID: entry.integrationID,
|
||||
methodID: Integration.MethodID.make(suffix),
|
||||
})
|
||||
}
|
||||
if (registrations.length > 0)
|
||||
yield* integration.transform((draft) => {
|
||||
for (const reg of registrations) {
|
||||
draft.update(reg.integrationID, (ref) => {
|
||||
ref.name = reg.name
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
|
||||
integrationID: reg.integrationID,
|
||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
||||
})
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
})
|
||||
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
|
||||
}
|
||||
})
|
||||
|
||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const name = ServerName.make(server)
|
||||
@@ -422,44 +420,36 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
|
||||
const whenLive =
|
||||
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
<E>(effect: Effect.Effect<void, E>) =>
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
connection.onClose(() => {
|
||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
||||
if (entry.client !== connection) return
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() => {
|
||||
fork(
|
||||
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||
locks.withLock(name),
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose(() =>
|
||||
live(
|
||||
Effect.gen(function* () {
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() =>
|
||||
live(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||
),
|
||||
),
|
||||
)
|
||||
connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
|
||||
connection.onResourcesChanged(() => live(events.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||
})
|
||||
connection.onPromptsChanged(() => {
|
||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
||||
})
|
||||
connection.onResourcesChanged(() => {
|
||||
if (entry.client !== connection) return
|
||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
@@ -482,10 +472,6 @@ export const layer = Layer.effect(
|
||||
|
||||
const startServer = (name: ServerName, entry: ServerEntry) =>
|
||||
Effect.gen(function* () {
|
||||
// Announce the handshake so connect() and credential reconnects don't show a stale
|
||||
// disabled/failed status for the duration of the connection attempt.
|
||||
entry.status = { status: "pending" }
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
@@ -509,7 +495,7 @@ export const layer = Layer.effect(
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
||||
return
|
||||
}
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
@@ -523,19 +509,6 @@ export const layer = Layer.effect(
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
const scope = entry.scope
|
||||
if (!scope) return
|
||||
entry.scope = undefined
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
// Disabled servers settle their startup immediately so queries never block on them.
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.disabled) {
|
||||
@@ -543,24 +516,27 @@ export const layer = Layer.effect(
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
fork(startServer(name, entry))
|
||||
}
|
||||
|
||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
||||
const owned = new Set(registrations.map((reg) => reg.integrationID))
|
||||
const reconnect = (integrationID: Integration.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||
if (!match) return
|
||||
const name = match[0]
|
||||
yield* Effect.gen(function* () {
|
||||
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
|
||||
const entry = runtime.get(name)
|
||||
if (!entry || entry.integrationID !== integrationID) return
|
||||
if (entry.status.status === "disabled") return
|
||||
yield* stopServer(name, entry)
|
||||
yield* startServer(name, entry)
|
||||
}).pipe(locks.withLock(name))
|
||||
const [name, entry] = match
|
||||
if (entry.config.disabled) return
|
||||
if (entry.scope) {
|
||||
yield* Scope.close(entry.scope, Exit.void)
|
||||
entry.scope = undefined
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
})
|
||||
fork(
|
||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
@@ -570,13 +546,10 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
const whenAllReady = Effect.suspend(() =>
|
||||
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
)
|
||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
return Service.of({
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
@@ -589,66 +562,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}),
|
||||
add: Effect.fn("MCP.add")(function* (server, config) {
|
||||
const name = ServerName.make(server)
|
||||
yield* Effect.gen(function* () {
|
||||
const previous = runtime.get(name)
|
||||
if (previous) {
|
||||
yield* stopServer(name, previous)
|
||||
if (previous.integrationID) owned.delete(previous.integrationID)
|
||||
if (previous.registration) yield* previous.registration.dispose
|
||||
}
|
||||
const entry: ServerEntry = {
|
||||
config: { ...config, timeout: { ...timeout, ...config.timeout } },
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
}
|
||||
runtime.set(name, entry)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* register(name, entry)
|
||||
if (config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
return
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
}).pipe(
|
||||
// Settle startup even when register fails or add is interrupted, so an entry that made it
|
||||
// into runtime can never hang readers awaiting its startup.
|
||||
Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
|
||||
)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
connect: Effect.fn("MCP.connect")(function* (server) {
|
||||
const name = ServerName.make(server)
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
yield* startServer(name, target.entry)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
disconnect: Effect.fn("MCP.disconnect")(function* (server) {
|
||||
const name = ServerName.make(server)
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
target.entry.status = { status: "disabled" }
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||
const name = ServerName.make(server)
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
if (target.entry.integrationID) owned.delete(target.entry.integrationID)
|
||||
if (target.entry.registration) yield* target.entry.registration.dispose
|
||||
// Credentials are kept: they are keyed by name + url, so re-adding the same server
|
||||
// reuses them without forcing re-auth, matching add()'s replacement semantics.
|
||||
runtime.delete(name)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
tools: Effect.fn("MCP.tools")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(runtime.values())
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionErrors } from "./session/error"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
@@ -99,11 +98,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionV2.NotFoundError>
|
||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionV2.NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly get: (id: ID) => Effect.Effect<Request | undefined>
|
||||
readonly forSession: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Request>>
|
||||
readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect<ReadonlyArray<Request>>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
@@ -143,12 +142,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const configured = Effect.fn("PermissionV2.configured")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
agentID?: AgentV2.ID,
|
||||
) {
|
||||
const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) {
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
|
||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
@@ -305,7 +301,7 @@ const layer = Layer.effect(
|
||||
return pending.get(id)?.request
|
||||
})
|
||||
|
||||
const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
|
||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import { PermissionV2 } from "../permission"
|
||||
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
export const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
|
||||
export function allowExternalDirectories(resources: readonly string[]): PermissionV2.Ruleset {
|
||||
return resources.map((resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }))
|
||||
}
|
||||
@@ -7,10 +7,8 @@ import { AgentV2 } from "../agent"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SHELL_OUTPUT_GLOB } from "../permission/defaults"
|
||||
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
const BUILD_SYSTEM =
|
||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ const pre = [
|
||||
|
||||
const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
|
||||
@@ -133,20 +133,12 @@ const device = {
|
||||
},
|
||||
Device,
|
||||
).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((created) => {
|
||||
const lifetime = positiveSeconds(value.expires_in, 0)
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: value.verification_uri_complete ?? value.verification_uri,
|
||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
|
||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.map((value) => ({
|
||||
mode: "auto" as const,
|
||||
url: value.verification_uri_complete ?? value.verification_uri,
|
||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||
})),
|
||||
),
|
||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
@@ -142,11 +142,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
continue
|
||||
}
|
||||
|
||||
const plugin = yield* load(operation).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
if (!plugin) continue
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
|
||||
@@ -28,9 +28,9 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { MessageDecodeError, NotFoundError } from "./session/error"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionPending } from "./session/pending"
|
||||
import { SessionGenerate } from "./session/generate"
|
||||
@@ -108,6 +108,10 @@ type ForkInput = {
|
||||
messageID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
@@ -115,7 +119,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
||||
},
|
||||
) {}
|
||||
|
||||
export { MessageDecodeError, NotFoundError }
|
||||
export { MessageDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
export * as SessionErrors from "./error"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -410,9 +410,7 @@ const layer = Layer.effect(
|
||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: unsettled.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
|
||||
@@ -69,6 +69,10 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillDiscovery") {}
|
||||
|
||||
export function cachePath(cache: string, url: string) {
|
||||
return path.resolve(cache, "skills", Hash.fast(url.endsWith("/") ? url : `${url}/`))
|
||||
}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -111,7 +115,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
if (!data) return []
|
||||
|
||||
const sourceRoot = path.resolve(global.cache, "skills", Hash.fast(base))
|
||||
const sourceRoot = cachePath(global.cache, base)
|
||||
return yield* Effect.forEach(
|
||||
data.skills.flatMap((skill) => {
|
||||
if (!isSafeSegment(skill.name)) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SHELL_OUTPUT_GLOB } from "@opencode-ai/core/permission/defaults"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
@@ -22,6 +23,11 @@ const defaultPermissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "*", effect: "ask" },
|
||||
] satisfies PermissionV2.Ruleset
|
||||
const shellOutputPermission = {
|
||||
action: "external_directory",
|
||||
resource: SHELL_OUTPUT_GLOB,
|
||||
effect: "allow",
|
||||
} satisfies PermissionV2.Rule
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("matches POSIX paths against home-relative permissions", () =>
|
||||
@@ -114,6 +120,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "git *", effect: "allow" },
|
||||
shellOutputPermission,
|
||||
])
|
||||
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
|
||||
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
|
||||
@@ -132,6 +139,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "deny" },
|
||||
shellOutputPermission,
|
||||
])
|
||||
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
|
||||
@@ -139,11 +147,31 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
shellOutputPermission,
|
||||
])
|
||||
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps shell output readable through a broad external-directory deny", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadConfiguredPermissions([
|
||||
{ action: "external_directory", resource: "*", effect: "deny" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("respects an exact shell output deny", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadConfiguredPermissions([
|
||||
{ action: "external_directory", resource: "*", effect: "deny" },
|
||||
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB, effect: "deny" },
|
||||
])
|
||||
expect(PermissionV2.evaluate("external_directory", SHELL_OUTPUT_GLOB, permissions).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps configured agent fields and preserves an unspecified model variant", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
@@ -294,13 +322,21 @@ Use native v2 fields.`,
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [
|
||||
...defaultPermissions,
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
shellOutputPermission,
|
||||
],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [
|
||||
...defaultPermissions,
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
shellOutputPermission,
|
||||
],
|
||||
})
|
||||
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })
|
||||
@@ -357,3 +393,26 @@ function loadHomePermissions(home: string) {
|
||||
return agent.permissions
|
||||
})
|
||||
}
|
||||
|
||||
function loadConfiguredPermissions(permissions: PermissionV2.Ruleset) {
|
||||
return Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const build = AgentV2.ID.make("build")
|
||||
yield* agents.transform((draft) => draft.update(build, () => {}))
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({ permissions }),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
)
|
||||
const agent = yield* agents.get(build)
|
||||
if (!agent) throw new Error("expected configured build agent")
|
||||
return agent.permissions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -111,15 +111,8 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("logs invalid packages and continues loading", () => {
|
||||
const output: string[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
if (!Array.isArray(entry.message) || entry.message[0] !== "failed to load plugin") return
|
||||
const details = entry.message[1]
|
||||
if (typeof details !== "object" || details === null || !("target" in details)) return
|
||||
if (typeof details.target === "string") output.push(details.target)
|
||||
})
|
||||
return withLocation(
|
||||
it.live("ignores invalid packages and continues loading", () =>
|
||||
withLocation(
|
||||
{
|
||||
plugins: [
|
||||
"-*",
|
||||
@@ -137,13 +130,9 @@ describe("PluginSupervisor config", () => {
|
||||
expect(yield* agents.get(AgentV2.ID.make("configured"))).toMatchObject({
|
||||
description: "Loaded after invalid plugins",
|
||||
})
|
||||
expect(output).toEqual([
|
||||
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
|
||||
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
|
||||
])
|
||||
}),
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin files", () =>
|
||||
withLocation(
|
||||
|
||||
@@ -34,8 +34,10 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const agent = host().agent
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: { ...agent, transform: () => Effect.succeed({ dispose: Effect.void }) },
|
||||
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
|
||||
}),
|
||||
).pipe(
|
||||
|
||||
@@ -9,10 +9,6 @@ export const emptyMcpLayer = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
servers: () => Effect.succeed([]),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
tools: () => Effect.succeed([]),
|
||||
callTool: () => Effect.die("unused mcp.callTool"),
|
||||
instructions: () => Effect.succeed([]),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -414,41 +414,6 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider-defined OAuth attempt expirations", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const created = yield* Clock.currentTimeMillis
|
||||
const expirations = [
|
||||
created + Duration.toMillis(Duration.minutes(5)),
|
||||
created + Duration.toMillis(Duration.minutes(20)),
|
||||
]
|
||||
|
||||
yield* Effect.forEach(expirations, (expiresAt, index) => {
|
||||
const methodID = Integration.MethodID.make(`browser-${index}`)
|
||||
return Effect.gen(function* () {
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Sign in",
|
||||
expiresAt,
|
||||
callback: Effect.never,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time).toEqual({ created, expires: expiresAt })
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects credential and env connections", () => {
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
return Effect.acquireUseRelease(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
@@ -112,6 +113,70 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows external skill and reference directories by default", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([project, external]) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = path.join(external.path, "skills", "example")
|
||||
const reference = path.join(external.path, "reference")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.mkdir(skill, { recursive: true }),
|
||||
fs.mkdir(reference, { recursive: true }),
|
||||
fs.writeFile(
|
||||
path.join(project.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
skills: [path.join(external.path, "skills")],
|
||||
references: { docs: reference },
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(skill, "SKILL.md"),
|
||||
"---\nname: example\ndescription: Example skill.\n---\n\n# Example\n",
|
||||
),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = locations.get(Location.Ref.make({ directory: AbsolutePath.make(project.path) }))
|
||||
const permissions = yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* supervisor.flush
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const build = yield* agents.resolve("build")
|
||||
if (
|
||||
build &&
|
||||
PermissionV2.evaluate(
|
||||
"external_directory",
|
||||
path.join(skill, "reference", "notes.md"),
|
||||
build.permissions,
|
||||
).effect === "allow" &&
|
||||
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), build.permissions)
|
||||
.effect === "allow"
|
||||
)
|
||||
return build.permissions
|
||||
yield* Effect.sleep("20 millis")
|
||||
}
|
||||
return (yield* agents.resolve("build"))?.permissions ?? []
|
||||
}).pipe(Effect.scoped, Effect.provide(context))
|
||||
|
||||
expect(
|
||||
PermissionV2.evaluate("external_directory", path.join(skill, "reference", "notes.md"), permissions).effect,
|
||||
).toBe("allow")
|
||||
expect(
|
||||
PermissionV2.evaluate("external_directory", path.join(reference, "notes.md"), permissions).effect,
|
||||
).toBe("allow")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -148,10 +148,7 @@ function resourceServer(
|
||||
)
|
||||
}
|
||||
|
||||
function resourceMcpLayer(
|
||||
server: string | typeof ConfigMCP.Server.Type,
|
||||
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||
) {
|
||||
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
||||
const directory = AbsolutePath.make(import.meta.dir)
|
||||
const unusedIntegration = () => Effect.die("unused integration service")
|
||||
return MCP.layer.pipe(
|
||||
@@ -167,12 +164,7 @@ function resourceMcpLayer(
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
mcp: new ConfigMCP.Info({
|
||||
servers: {
|
||||
resources:
|
||||
typeof server === "string"
|
||||
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
|
||||
: server,
|
||||
},
|
||||
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -642,120 +634,6 @@ test("loads and reads MCP resources", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
yield* service.add(
|
||||
"dynamic",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
}),
|
||||
)
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "connected",
|
||||
})
|
||||
|
||||
yield* service.add(
|
||||
"dynamic",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
)
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "disabled",
|
||||
})
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
yield* service.connect("dynamic")
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "connected",
|
||||
})
|
||||
yield* service.disconnect("dynamic")
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "disabled",
|
||||
})
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
yield* service.connect("dynamic")
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "connected",
|
||||
})
|
||||
|
||||
yield* service.remove("dynamic")
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
|
||||
// Whatever order the racing operations land in, the resulting state must be consistent.
|
||||
yield* Effect.all(
|
||||
[
|
||||
service.connect("resources"),
|
||||
service.connect("resources"),
|
||||
service.disconnect("resources"),
|
||||
service.connect("resources"),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
|
||||
const tools = yield* service.tools()
|
||||
expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
|
||||
if (status?.status === "disabled") expect(tools).toEqual([])
|
||||
if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
|
||||
|
||||
yield* service.disconnect("resources")
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
yield* service.connect("resources")
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
|
||||
expect((yield* service.tools()).length).toBeGreaterThan(0)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
@@ -1892,35 +1892,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("records cancelled manual compaction without surfacing an internal failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
response = reply.text("Earlier answer", "text-manual-interrupt-history")
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const streamed = yield* Deferred.make<void>()
|
||||
const partial = fragmentFixture("text", "text-manual-interrupt-summary", ["Partial summary"])
|
||||
responseStream = Stream.concat(
|
||||
Stream.fromIterable(partial.partialEvents),
|
||||
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
|
||||
)
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamed)
|
||||
yield* session.interrupt(sessionID)
|
||||
|
||||
yield* Fiber.await(run)
|
||||
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
||||
import { realpathSync } from "node:fs"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||
const progressOverflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; while [ ! -e '${release}' ]; do sleep 0.05; done`
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
|
||||
|
||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -417,49 +417,33 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reports bounded output progress for a running command",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const release = "shell-progress-release"
|
||||
const releasePath = path.join(tmp.path, release)
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const observed = yield* Deferred.make<ToolRegistry.Progress>()
|
||||
yield* settleTool(registry, {
|
||||
...call(
|
||||
{ command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) },
|
||||
"call-progress",
|
||||
),
|
||||
progress: (update) =>
|
||||
Effect.gen(function* () {
|
||||
if (update.structured.truncated !== true) return
|
||||
const content = update.content[0]
|
||||
if (content?.type !== "text") return
|
||||
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
|
||||
return
|
||||
yield* Deferred.succeed(observed, update)
|
||||
yield* Effect.promise(() => fs.writeFile(releasePath, ""))
|
||||
}),
|
||||
})
|
||||
it.live("reports bounded output progress for a running command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const progress: ToolRegistry.Progress[] = []
|
||||
yield* settleTool(registry, {
|
||||
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
})
|
||||
|
||||
const progress = yield* Deferred.await(observed)
|
||||
expect(progress.structured).toEqual({ truncated: true })
|
||||
const content = progress.content[0]
|
||||
expect(content?.type).toBe("text")
|
||||
if (content?.type !== "text") return
|
||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
||||
ShellTool.MAX_CAPTURE_BYTES,
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
expect(progress).toHaveLength(1)
|
||||
expect(progress[0]?.structured).toEqual({ truncated: true })
|
||||
const content = progress[0]?.content[0]
|
||||
expect(content?.type).toBe("text")
|
||||
if (content?.type !== "text") return
|
||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
||||
ShellTool.MAX_CAPTURE_BYTES,
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns a useful timeout settlement", () =>
|
||||
|
||||
@@ -22,12 +22,12 @@ You can also install it with the following package managers.
|
||||
</Tab>
|
||||
<Tab title="bun">
|
||||
```bash
|
||||
bun install -g --trust @opencode-ai/cli@next
|
||||
bun install -g @opencode-ai/cli@next
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="pnpm">
|
||||
```bash
|
||||
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
|
||||
pnpm install -g @opencode-ai/cli@next
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Yarn">
|
||||
@@ -37,9 +37,6 @@ You can also install it with the following package managers.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
The package uses a trusted postinstall script to select the native binary for your platform. The Bun and pnpm commands
|
||||
above explicitly allow that script to run.
|
||||
|
||||
<Note>During beta, the binary is called `opencode2`.</Note>
|
||||
|
||||
### Homebrew
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { Transform } from "./registration.js"
|
||||
export type IntegrationOAuthAuthorization = {
|
||||
readonly url: string
|
||||
readonly instructions: string
|
||||
readonly expiresAt?: number
|
||||
} & (
|
||||
| {
|
||||
readonly mode: "auto"
|
||||
|
||||
@@ -21,10 +21,7 @@ export type Options<E = never, R = never> = {
|
||||
readonly password: string
|
||||
readonly instanceID: string
|
||||
readonly service?: {
|
||||
readonly onListen: (
|
||||
address: HttpServer.Address,
|
||||
shutdown: Effect.Effect<void>,
|
||||
) => Effect.Effect<Effect.Effect<void>, E, R>
|
||||
readonly onListen: (address: HttpServer.Address) => Effect.Effect<void, E, R>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +44,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
||||
yield* bound.http
|
||||
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger)
|
||||
.pipe(withoutParentSpan)
|
||||
if (options.service)
|
||||
yield* options.service.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
|
||||
Effect.flatMap((cleanup) =>
|
||||
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))),
|
||||
),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
if (options.service) yield* options.service.onListen(bound.http.address)
|
||||
|
||||
const parentScope = yield* Scope.Scope
|
||||
const applicationScope = yield* Scope.fork(parentScope)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
status.beginStopping.pipe(
|
||||
Effect.andThen(Ref.set(application, Option.none())),
|
||||
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
|
||||
),
|
||||
status
|
||||
.beginStopping
|
||||
.pipe(
|
||||
Effect.andThen(Ref.set(application, Option.none())),
|
||||
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
|
||||
),
|
||||
)
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
@@ -119,7 +112,7 @@ function bind(hostname: string, port: number) {
|
||||
return yield* Effect.gen(function* () {
|
||||
const http = yield* NodeHttpServer.make(() => server, { port, host: hostname })
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))
|
||||
return { http, server, scope: serverScope }
|
||||
return { http, server }
|
||||
}).pipe(
|
||||
Effect.provideService(Scope.Scope, serverScope),
|
||||
Effect.onError((cause) => Scope.close(serverScope, Exit.failCause(cause))),
|
||||
@@ -197,13 +190,10 @@ const control = Effect.fnUntraced(function* (
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{ healthy: true, version: InstallationVersion, pid: process.pid },
|
||||
{
|
||||
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
},
|
||||
)
|
||||
return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
|
||||
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
function unavailable(status: Status.State) {
|
||||
|
||||
+79
-139
@@ -81,10 +81,6 @@ import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime } from "./
|
||||
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
import { ServerProvider, decodeServerURLs, useServer, type ServerConnection } from "./context/server"
|
||||
import { DialogServer } from "./component/dialog-server"
|
||||
import { readJson, writeJsonAtomic } from "./util/persistence"
|
||||
import path from "path"
|
||||
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
@@ -98,7 +94,6 @@ registerOpencodeSpinner()
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
"session.new",
|
||||
"server.switch",
|
||||
"session.quick_switch.1",
|
||||
"session.quick_switch.2",
|
||||
"session.quick_switch.3",
|
||||
@@ -147,7 +142,6 @@ const appBindingCommands = [
|
||||
export type TuiInput = {
|
||||
server: {
|
||||
endpoint: Endpoint
|
||||
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
|
||||
service?: {
|
||||
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||
restart: () => Promise<void>
|
||||
@@ -188,16 +182,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
|
||||
terminalSuspend: process.platform !== "win32",
|
||||
})
|
||||
const initialAPI = OpenCode.make({
|
||||
baseUrl: input.server.endpoint.url,
|
||||
headers: Service.headers(input.server.endpoint),
|
||||
})
|
||||
yield* Effect.tryPromise(() => initialAPI.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.catch(() => Effect.tryPromise(() => initialAPI.location.get())),
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
)
|
||||
const serverFile = path.join(global.state, "tui-servers.json")
|
||||
const serverURLs = decodeServerURLs(yield* Effect.promise(() => readJson<unknown>(serverFile).catch(() => undefined)))
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const managed = input.server.service
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next) }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
: undefined
|
||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -313,36 +315,68 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ServerProvider
|
||||
initial={{ endpoint: input.server.endpoint, service: input.server.service }}
|
||||
urls={serverURLs}
|
||||
connect={input.server.connect}
|
||||
prepare={(endpoint) => {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
return api.file
|
||||
.list({ location: { directory: process.cwd() } })
|
||||
.catch(() => api.location.get())
|
||||
.then(() => undefined)
|
||||
}}
|
||||
save={(servers) => writeJsonAtomic(serverFile, { servers })}
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ServerScope input={input} pluginRuntime={pluginRuntime} started={appStarted} />
|
||||
</ServerProvider>
|
||||
</ThemeProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
started={appStarted}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
@@ -371,91 +405,6 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
type ServerScopeProps = {
|
||||
input: TuiInput
|
||||
pluginRuntime: ReturnType<typeof createPluginRuntime>
|
||||
started: number
|
||||
}
|
||||
|
||||
function ServerScope(props: ServerScopeProps) {
|
||||
const server = useServer()
|
||||
let startup = true
|
||||
return (
|
||||
<Show when={server.current} keyed>
|
||||
{(connection) => {
|
||||
const initial = startup
|
||||
startup = false
|
||||
return <ServerApp {...props} connection={connection} startup={initial} />
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerApp(props: ServerScopeProps & { connection: ServerConnection; startup: boolean }) {
|
||||
const api = OpenCode.make({
|
||||
baseUrl: props.connection.endpoint.url,
|
||||
headers: Service.headers(props.connection.endpoint),
|
||||
})
|
||||
const managed = props.connection.service
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
return {
|
||||
api: OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }),
|
||||
}
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
: undefined
|
||||
const args = props.startup ? props.input.args : {}
|
||||
return (
|
||||
<ArgsProvider {...args}>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
args.continue ? { type: "session", sessionID: "dummy" } : props.startup ? undefined : { type: "home" }
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={props.pluginRuntime}>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<PluginProvider packages={props.input.packages}>
|
||||
<App
|
||||
started={props.started}
|
||||
pair={
|
||||
props.connection.endpoint.auth ?? {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ArgsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
@@ -471,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
@@ -817,15 +766,6 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "server.switch",
|
||||
title: "Switch server",
|
||||
slash: { name: "servers" },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogServer />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
@@ -1147,7 +1087,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={theme.background}
|
||||
onMouseDown={(evt) => {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
|
||||
if (evt.button !== MouseButton.RIGHT) return
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createSignal, For } from "solid-js"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
|
||||
export function DevToolsSidebar() {
|
||||
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
|
||||
const [modeHovered, setModeHovered] = createSignal(false)
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -17,27 +16,6 @@ export function DevToolsSidebar() {
|
||||
paddingRight={2}
|
||||
backgroundColor={themeV2.background()}
|
||||
>
|
||||
<box flexShrink={0} marginBottom={1}>
|
||||
<box marginBottom={1}>
|
||||
<text fg={themeV2.text.action()} attributes={TextAttributes.BOLD}>
|
||||
Theme
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={themeV2.text.subdued()}>Mode</text>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
|
||||
onMouseOver={() => setModeHovered(true)}
|
||||
onMouseOut={() => setModeHovered(false)}
|
||||
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<text fg={themeV2.text()}>{mode()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<For each={DevTools.data()}>
|
||||
{(group) => (
|
||||
<box flexShrink={0} marginBottom={1}>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function connectionSummary(integration: IntegrationInfo) {
|
||||
export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const options = createMemo(() =>
|
||||
integrationOptions(data.location.integration.list() ?? []).map((integration) => {
|
||||
const methods = connectMethods(integration)
|
||||
@@ -74,7 +74,7 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
||||
footer: connectionSummary(integration) || undefined,
|
||||
category: integration.id in INTEGRATION_PRIORITY ? "Popular" : "Services",
|
||||
disabled: methods.length === 0,
|
||||
gutter: connected ? () => <text fg={themeV2.text.feedback.success()}>✓</text> : undefined,
|
||||
gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
onSelect: () =>
|
||||
credentialConnections(integration).length
|
||||
? manageConnections(integration, methods, dialog, props.onConnected)
|
||||
@@ -89,12 +89,12 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
|
||||
options={options()}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No integrations available</text>
|
||||
<text fg={theme.textMuted}>No integrations available</text>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No integrations found</text>
|
||||
<text fg={theme.textMuted}>No integrations found</text>
|
||||
</box>
|
||||
}
|
||||
/>
|
||||
@@ -289,30 +289,23 @@ function CommandPending(props: {
|
||||
|
||||
function CommandView(props: { title: string; output: string; message: string }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const { theme } = useTheme()
|
||||
onMount(() => dialog.setSize("large"))
|
||||
return (
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc close
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background()}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<text fg={overlayTheme.text()}>{props.output.trim()}</text>
|
||||
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text fg={theme.text}>{props.output.trim()}</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
@@ -327,7 +320,7 @@ function KeyMethod(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
return (
|
||||
@@ -345,9 +338,7 @@ function KeyMethod(props: {
|
||||
.then(() => connected(props.integration, data, dialog, toast, props.onConnected))
|
||||
.catch((cause) => setError(message(cause)))
|
||||
}}
|
||||
description={() => (
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
|
||||
)}
|
||||
description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -502,7 +493,7 @@ function OAuthCode(props: {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const [error, setError] = createSignal<string>()
|
||||
let settled = false
|
||||
|
||||
@@ -536,9 +527,9 @@ function OAuthCode(props: {
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={themeV2.text.subdued()}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={themeV2.markdown.link()} />
|
||||
<Show when={error()}>{(value) => <text fg={themeV2.text.feedback.error()}>{value()}</text>}</Show>
|
||||
<text fg={theme.textMuted}>{props.attempt.instructions}</text>
|
||||
<Link href={props.attempt.url} fg={theme.primary} />
|
||||
<Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
@@ -547,31 +538,31 @@ function OAuthCode(props: {
|
||||
|
||||
function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={props.url}>
|
||||
{(url) => (
|
||||
<box gap={1}>
|
||||
<Link href={url()} fg={themeV2.markdown.link()} />
|
||||
<Link href={url()} fg={theme.primary} />
|
||||
<Show when={props.instructions}>
|
||||
{(instructions) => <text fg={themeV2.text.subdued()}>{instructions()}</text>}
|
||||
{(instructions) => <text fg={theme.textMuted}>{instructions()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={themeV2.text.subdued()}>{props.message}</text>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
<Show when={props.copy}>
|
||||
<text fg={themeV2.text()}>
|
||||
c <span style={{ fg: themeV2.text.subdued() }}>copy</span>
|
||||
<text fg={theme.text}>
|
||||
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Keymap } from "../context/keymap"
|
||||
import { pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTheme, type Theme } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
@@ -12,59 +12,30 @@ import { useToast } from "../ui/toast"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import type { ComponentTheme } from "../theme/v2/component"
|
||||
|
||||
// Sort by how much attention a server needs: auth prompts first, then failures,
|
||||
// then healthy servers, and intentionally-off servers last.
|
||||
function statusMeta(status: McpServer["status"], themeV2: ComponentTheme) {
|
||||
function statusMeta(status: McpServer["status"], theme: Theme) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return {
|
||||
rank: 0,
|
||||
icon: "!",
|
||||
label: "Needs authentication",
|
||||
color: themeV2.text.feedback.warning(),
|
||||
error: undefined,
|
||||
bold: false,
|
||||
}
|
||||
return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false }
|
||||
case "needs_client_registration":
|
||||
return {
|
||||
rank: 1,
|
||||
icon: "✗",
|
||||
label: "Needs registration",
|
||||
color: themeV2.text.feedback.error(),
|
||||
error: status.error,
|
||||
bold: false,
|
||||
}
|
||||
return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false }
|
||||
case "failed":
|
||||
return {
|
||||
rank: 2,
|
||||
icon: "✗",
|
||||
label: "Failed",
|
||||
color: themeV2.text.feedback.error(),
|
||||
error: status.error,
|
||||
bold: false,
|
||||
}
|
||||
return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false }
|
||||
case "connected":
|
||||
return {
|
||||
rank: 3,
|
||||
icon: "✓",
|
||||
label: "Connected",
|
||||
color: themeV2.text.feedback.success(),
|
||||
error: undefined,
|
||||
bold: true,
|
||||
}
|
||||
return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true }
|
||||
case "pending":
|
||||
return { rank: 4, icon: "◌", label: "Pending", color: themeV2.text.subdued(), error: undefined, bold: false }
|
||||
return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false }
|
||||
default:
|
||||
return { rank: 5, icon: "○", label: "Disabled", color: themeV2.text.subdued(), error: undefined, bold: false }
|
||||
return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
|
||||
@@ -76,7 +47,7 @@ export function DialogMcp() {
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
sortBy(
|
||||
(server) => statusMeta(server.status, themeV2).rank,
|
||||
(server) => statusMeta(server.status, theme).rank,
|
||||
(server) => server.name,
|
||||
),
|
||||
),
|
||||
@@ -90,7 +61,7 @@ export function DialogMcp() {
|
||||
|
||||
const options = createMemo(() =>
|
||||
servers().map((server) => {
|
||||
const meta = statusMeta(server.status, themeV2)
|
||||
const meta = statusMeta(server.status, theme)
|
||||
return {
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
@@ -106,12 +77,12 @@ export function DialogMcp() {
|
||||
const focusedError = createMemo(() => {
|
||||
const name = focused()
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
return server ? statusMeta(server.status, themeV2).error : undefined
|
||||
return server ? statusMeta(server.status, theme).error : undefined
|
||||
})
|
||||
|
||||
const open = (name: string | undefined) => {
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
if (!server || !statusMeta(server.status, themeV2).error) return
|
||||
if (!server || !statusMeta(server.status, theme).error) return
|
||||
setDetail(server)
|
||||
}
|
||||
|
||||
@@ -129,7 +100,7 @@ export function DialogMcp() {
|
||||
onSelect={(option) => open(option.value as string)}
|
||||
footer={
|
||||
<Show when={focusedError()}>
|
||||
<text fg={themeV2.text.subdued()}>enter to view error</text>
|
||||
<text fg={theme.textMuted}>enter to view error</text>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
@@ -145,12 +116,11 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig().data
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const error = () => statusMeta(props.server.status, themeV2).error ?? "Unknown MCP connection error"
|
||||
const error = () => statusMeta(props.server.status, theme).error ?? "Unknown MCP connection error"
|
||||
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
@@ -182,35 +152,29 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||
return (
|
||||
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
MCP server: {props.server.name}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={props.onBack}>
|
||||
<text fg={theme.textMuted} onMouseUp={props.onBack}>
|
||||
esc back
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.feedback.error()}>✗ Failed</text>
|
||||
<box
|
||||
backgroundColor={overlayTheme.background()}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<text fg={theme.error}>✗ Failed</text>
|
||||
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={getScrollAcceleration(config)}
|
||||
>
|
||||
<text fg={overlayTheme.text()} wrapMode="word">
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{error()}
|
||||
</text>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={themeV2.text.subdued()}>↑↓ scroll</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={copy}>
|
||||
<text fg={theme.textMuted}>↑↓ scroll</text>
|
||||
<text fg={theme.textMuted} onMouseUp={copy}>
|
||||
{copied() ? "✓ copied" : "c copy details"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const sessionData = useData()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -172,18 +172,16 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return {
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: themeV2.text.feedback.error() }}>Deleting {item.location}</span>
|
||||
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||
) : deleting ? (
|
||||
<span style={{ fg: themeV2.text.action.destructive() }}>
|
||||
Press {shortcuts.get("dialog.move_session.delete")} again to confirm
|
||||
</span>
|
||||
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
|
||||
) : suffix ? (
|
||||
<>
|
||||
{visible.slice(0, split)}
|
||||
<span style={{ fg: themeV2.text.subdued() }}>{visible.slice(split)}</span>
|
||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||
</>
|
||||
) : undefined,
|
||||
bg: deleting ? themeV2.background.action.destructive() : undefined,
|
||||
bg: deleting ? theme.error : undefined,
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
@@ -316,7 +314,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
title="Move session"
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
@@ -329,25 +327,25 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.feedback.error()} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load project directories
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(loadError())}</text>
|
||||
<text fg={themeV2.text.subdued()}>Close and reopen Move session to try again.</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
|
||||
</box>
|
||||
) : directories.loading || loadedProject.loading ? (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>Loading project directories…</text>
|
||||
<text fg={theme.textMuted}>Loading project directories…</text>
|
||||
</box>
|
||||
) : (
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No project directories available</text>
|
||||
<text fg={theme.textMuted}>No project directories available</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
||||
<text fg={themeV2.text.subdued()}>No project directories found</text>
|
||||
<text fg={theme.textMuted}>No project directories found</text>
|
||||
</box>
|
||||
}
|
||||
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { selectedForeground, useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
@@ -38,9 +38,10 @@ function panelOverlay(color: RGBA) {
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const fg = selectedForeground(theme)
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(themeV2.background()) : undefined)
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -85,26 +86,26 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()} bg={textBg()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={themeV2.text.subdued()} bg={textBg()}>
|
||||
<text fg={theme.textMuted} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link()} bg={textBg()} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={themeV2.markdown.link()} wrapMode="none" />
|
||||
<Link href={props.link} fg={theme.primary} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
@@ -114,14 +115,12 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? themeV2.text.action("focused") : themeV2.text.subdued()}
|
||||
fg={selected() === "dismiss" ? fg : theme.textMuted}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
@@ -131,12 +130,12 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={selected() === "action" ? themeV2.background.action("focused") : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? themeV2.text.action("focused") : themeV2.text()}
|
||||
fg={selected() === "action" ? fg : theme.text}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServer } from "../context/server"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogServer() {
|
||||
const server = useServer()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const options = createMemo(() =>
|
||||
server.list().map((item) => ({
|
||||
title: item.name,
|
||||
description: item.url,
|
||||
value: item.id,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
void server
|
||||
.select(item.id)
|
||||
.then(() => toast.show({ variant: "success", message: `Switched to ${item.name}` }), toast.error)
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
function add() {
|
||||
void DialogPrompt.show(dialog, "Add server", {
|
||||
placeholder: "https://devbox.example",
|
||||
description: () => <text>Enter the URL of an OpenCode V2 server.</text>,
|
||||
}).then((value) => {
|
||||
if (!value) return
|
||||
dialog.clear()
|
||||
void server
|
||||
.add(value)
|
||||
.then(() => toast.show({ variant: "success", message: `Connected to ${server.current.name}` }), toast.error)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Switch server"
|
||||
options={options()}
|
||||
current={server.current.id}
|
||||
actions={[{ command: "server.add", title: "Add server", selection: "none", onTrigger: add }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
@@ -64,17 +64,17 @@ export function DialogSessionDeleteFailed(props: {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
@@ -86,7 +86,7 @@ export function DialogSessionDeleteFailed(props: {
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? themeV2.background.action("focused") : undefined}
|
||||
backgroundColor={item.id === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
@@ -94,14 +94,11 @@ export function DialogSessionDeleteFailed(props: {
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text()}
|
||||
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}
|
||||
wrapMode="word"
|
||||
>
|
||||
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -31,15 +31,14 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
message?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const { theme } = useTheme()
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(() =>
|
||||
Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
|
||||
const fileNameWidth = createMemo(
|
||||
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
|
||||
)
|
||||
|
||||
function confirm() {
|
||||
@@ -72,21 +71,21 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title ?? "File Changes Found"}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<text fg={themeV2.text.subdued()} wrapMode="word">
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{props.message ?? "Do you want to move these changes with the session?"}
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
height={height()}
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
@@ -95,19 +94,15 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||
<box flexDirection="row" minWidth={0} flexShrink={1}>
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={overlayTheme.text.subdued()}>{statusLabel(item.status)}</text>
|
||||
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={overlayTheme.text.subdued()} />
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
{" "}
|
||||
{item.additions ? (
|
||||
<span style={{ fg: overlayTheme.diff.text.added() }}>+{item.additions}</span>
|
||||
) : null}
|
||||
{item.deletions ? (
|
||||
<span style={{ fg: overlayTheme.diff.text.removed() }}> -{item.deletions}</span>
|
||||
) : null}
|
||||
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
|
||||
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -120,14 +115,14 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? themeV2.background.action("focused") : undefined}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
props.onSelect(item)
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? themeV2.text.action("focused") : themeV2.text.subdued()}>{item}</text>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -81,8 +81,6 @@ export const Definitions = {
|
||||
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||
status_view: keybind("<leader>s", "View status"),
|
||||
debug_view: keybind("none", "View debug info"),
|
||||
server_switch: keybind("<leader>w", "Switch server"),
|
||||
server_add: keybind("ctrl+a", "Add server"),
|
||||
|
||||
session_export: keybind("<leader>x", "Export session to editor"),
|
||||
session_copy: keybind("none", "Copy session transcript"),
|
||||
@@ -285,8 +283,6 @@ export const CommandMap = {
|
||||
scrollbar_toggle: "session.toggle.scrollbar",
|
||||
status_view: "opencode.status",
|
||||
debug_view: "opencode.debug",
|
||||
server_switch: "server.switch",
|
||||
server_add: "server.add",
|
||||
session_export: "session.export",
|
||||
session_copy: "session.copy",
|
||||
session_move: "session.move",
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export type ServerConnection = {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
endpoint: Endpoint
|
||||
service?: {
|
||||
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerInfo = Pick<ServerConnection, "id" | "name" | "url">
|
||||
|
||||
type ServerContext = {
|
||||
readonly current: ServerConnection
|
||||
list: () => ServerInfo[]
|
||||
select: (id: string) => Promise<void>
|
||||
add: (url: string) => Promise<void>
|
||||
}
|
||||
|
||||
const context = createContext<ServerContext>()
|
||||
|
||||
export function ServerProvider(
|
||||
props: ParentProps<{
|
||||
initial: Omit<ServerConnection, "id" | "name" | "url">
|
||||
urls: string[]
|
||||
connect: (url: string, signal?: AbortSignal) => Promise<Endpoint>
|
||||
prepare: (endpoint: Endpoint) => Promise<void>
|
||||
save: (urls: string[]) => Promise<void>
|
||||
}>,
|
||||
) {
|
||||
const initialURL = normalizeServerURL(props.initial.endpoint.url)
|
||||
const initial = {
|
||||
...props.initial,
|
||||
id: initialURL,
|
||||
name: serverName(initialURL),
|
||||
url: initialURL,
|
||||
}
|
||||
const [current, setCurrent] = createSignal(initial)
|
||||
const [urls, setURLs] = createSignal(
|
||||
props.urls.map(normalizeServerURL).filter((url, index, all) => url !== initialURL && all.indexOf(url) === index),
|
||||
)
|
||||
const list = createMemo(() => [
|
||||
{ id: initial.id, name: initial.name, url: initial.url },
|
||||
...urls().map((url) => ({ id: url, name: serverName(url), url })),
|
||||
])
|
||||
|
||||
async function select(id: string) {
|
||||
if (id === current().id) return
|
||||
if (id === initial.id) {
|
||||
setCurrent(initial)
|
||||
return
|
||||
}
|
||||
const info = list().find((item) => item.id === id)
|
||||
if (!info) throw new Error(`Unknown server: ${id}`)
|
||||
const endpoint = await props.connect(info.url)
|
||||
await props.prepare(endpoint)
|
||||
setCurrent({ ...info, endpoint })
|
||||
}
|
||||
|
||||
async function add(value: string) {
|
||||
const url = normalizeServerURL(value)
|
||||
const existing = list().find((item) => item.url === url)
|
||||
if (existing) return select(existing.id)
|
||||
const endpoint = await props.connect(url)
|
||||
await props.prepare(endpoint)
|
||||
const next = [...urls(), url]
|
||||
await props.save(next)
|
||||
setURLs(next)
|
||||
setCurrent({ id: url, name: serverName(url), url, endpoint })
|
||||
}
|
||||
|
||||
return (
|
||||
<context.Provider
|
||||
value={{
|
||||
get current() {
|
||||
return current()
|
||||
},
|
||||
list,
|
||||
select,
|
||||
add,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useServer() {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error("Server context must be used within a ServerProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
export function decodeServerURLs(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("servers" in input) || !Array.isArray(input.servers)) return []
|
||||
return input.servers.flatMap((item) => {
|
||||
if (typeof item !== "string") return []
|
||||
try {
|
||||
return [normalizeServerURL(item)]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeServerURL(value: string) {
|
||||
const trimmed = value.trim()
|
||||
const input = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
|
||||
if (!URL.canParse(input)) throw new Error(`Invalid server URL: ${trimmed || value}`)
|
||||
const url = new URL(input)
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Server URL must use HTTP or HTTPS")
|
||||
if (url.username || url.password) throw new Error("Server URL must not contain credentials")
|
||||
if (url.search || url.hash) throw new Error("Server URL must not contain a query or fragment")
|
||||
url.pathname = url.pathname.replace(/\/+$/, "") || "/"
|
||||
return url.href.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
export function serverName(value: string) {
|
||||
const url = new URL(value)
|
||||
if (["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return "Local"
|
||||
return url.host
|
||||
}
|
||||
@@ -26,8 +26,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
|
||||
function Scrap(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme()
|
||||
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [
|
||||
@@ -43,19 +42,19 @@ function Scrap(props: { context: Plugin.Context }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background()}>
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={elevatedTheme.background()}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued()}>~/code/anomalyco/opencode</text>
|
||||
<text fg={theme.textMuted}>~/code/anomalyco/opencode</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued()}>esc home</text>
|
||||
<text fg={theme.textMuted}>esc home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ interface Tab {
|
||||
const ComposerContext = createContext<{
|
||||
register: (tab: Tab) => () => void
|
||||
active: (id: string) => boolean
|
||||
close: () => void
|
||||
}>()
|
||||
|
||||
export function useComposerTab() {
|
||||
@@ -74,7 +73,6 @@ export function Composer(props: ComposerProps) {
|
||||
active(id: string) {
|
||||
return props.open && store.active === id
|
||||
},
|
||||
close,
|
||||
}
|
||||
|
||||
const keymap = Keymap.use()
|
||||
|
||||
@@ -59,11 +59,9 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
return
|
||||
}
|
||||
setStore("selected", (prev) => prev - 1)
|
||||
const list = entries()
|
||||
if (list.length === 0) return
|
||||
setStore("selected", (prev) => (prev - 1 + list.length) % list.length)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -160,11 +160,9 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
return
|
||||
}
|
||||
moveTo(store.selected - 1, true)
|
||||
const list = entries()
|
||||
if (list.length === 0) return
|
||||
moveTo((store.selected - 1 + list.length) % list.length, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1440,10 +1440,9 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
const ctx = use()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const status = () => props.message.status
|
||||
const cancelled = () => props.message.status === "failed" && props.message.error.type === "aborted"
|
||||
const text = () => (props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary)
|
||||
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
|
||||
const content = createMemo(() => text().trim())
|
||||
const color = () => (status() === "failed" && !cancelled() ? themeV2.text.feedback.error() : themeV2.text.subdued())
|
||||
const color = () => (status() === "failed" ? themeV2.text.feedback.error() : themeV2.text.subdued())
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" alignItems="center">
|
||||
@@ -1455,14 +1454,11 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={status() === "failed" && !cancelled()}>
|
||||
<Match when={status() === "failed"}>
|
||||
<text fg={color()}>✗</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={color()}>Compaction</text>
|
||||
<Show when={cancelled()}>
|
||||
<text fg={color()}>· cancelled</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
||||
</box>
|
||||
|
||||
@@ -77,7 +77,6 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>, mode:
|
||||
|
||||
return {
|
||||
hue,
|
||||
source: (color: RGBA) => current().source(color),
|
||||
increase: (color: RGBA, amount = 1) => current().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => current().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? current().increase(color) : current().decrease(color)),
|
||||
|
||||
@@ -32,7 +32,6 @@ export {
|
||||
export type {
|
||||
FormfieldColor,
|
||||
Hue,
|
||||
HueSource,
|
||||
HueScale,
|
||||
ResolvedActionState,
|
||||
ResolvedFormfieldState,
|
||||
|
||||
@@ -141,36 +141,35 @@ function contextualActions(
|
||||
function resolveView(
|
||||
definition: ThemeTokensDefinition,
|
||||
hue: ResolvedThemeView["hue"],
|
||||
hueSteps: Pick<ResolvedThemeView, "source" | "increase" | "decrease">,
|
||||
hueSteps: Pick<ResolvedThemeView, "increase" | "decrease">,
|
||||
): ResolvedThemeView {
|
||||
const source: Record<string, unknown> = { hue, ...definition }
|
||||
return { ...(createResolver(source)(source, "theme") as ResolvedThemeView), hue, ...hueSteps }
|
||||
}
|
||||
|
||||
function compileHueSteps(
|
||||
hue: ResolvedThemeView["hue"],
|
||||
): Pick<ResolvedThemeView, "source" | "increase" | "decrease"> {
|
||||
const index = new WeakMap<RGBA, { hue: keyof typeof hue; step: HueStep; position: number }>()
|
||||
for (const [name, scale] of Object.entries(hue) as [keyof typeof hue, HueScale][]) {
|
||||
HueStep.literals.forEach((step, position) => index.set(scale[step], { hue: name, step, position }))
|
||||
}
|
||||
function compileHueSteps(hue: ResolvedThemeView["hue"]): Pick<ResolvedThemeView, "increase" | "decrease"> {
|
||||
const index = new Map(
|
||||
Object.values(hue).flatMap((scale) =>
|
||||
HueStep.literals.map((step, position) => [colorKey(scale[step]), { position, scale }] as const),
|
||||
),
|
||||
)
|
||||
const shift = (color: RGBA, amount: number) => {
|
||||
const match = index.get(color)
|
||||
const match = index.get(colorKey(color))
|
||||
if (!match) return color
|
||||
const offset = Number.isFinite(amount) ? Math.trunc(amount) : 0
|
||||
const position = Math.max(0, Math.min(HueStep.literals.length - 1, match.position + offset))
|
||||
return hue[match.hue][HueStep.literals[position]]
|
||||
return match.scale[HueStep.literals[position]]
|
||||
}
|
||||
return {
|
||||
source: (color) => {
|
||||
const match = index.get(color)
|
||||
return match ? { hue: match.hue, step: match.step } : undefined
|
||||
},
|
||||
increase: (color, amount = 1) => shift(color, amount),
|
||||
decrease: (color, amount = 1) => shift(color, -amount),
|
||||
}
|
||||
}
|
||||
|
||||
function colorKey(color: RGBA) {
|
||||
return color.toInts().join(":")
|
||||
}
|
||||
|
||||
function resolveHue(definition: HueDefinition) {
|
||||
const source = definition as Record<string, unknown>
|
||||
const cache = new Map<string, HueScale>()
|
||||
@@ -187,8 +186,7 @@ function resolveHue(definition: HueDefinition) {
|
||||
if (typeof value === "string") {
|
||||
const match = /^\$hue\.([^.]+)$/.exec(value)
|
||||
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
|
||||
const target = resolve(match[1], [...stack, name])
|
||||
const result = Object.fromEntries(HueStep.literals.map((step) => [step, RGBA.clone(target[step])])) as HueScale
|
||||
const result = resolve(match[1], [...stack, name])
|
||||
cache.set(name, result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -15,13 +15,11 @@ export type ResolvedActionState = "default" | ActionState
|
||||
export type ResolvedFormfieldState = ResolvedActionState
|
||||
export type HueScale = Readonly<Record<HueStep, RGBA>>
|
||||
export type Hue = Readonly<Record<BaseHue | HueAlias, HueScale>>
|
||||
export type HueSource = Readonly<{ hue: BaseHue | HueAlias; step: HueStep }>
|
||||
export type StatefulColor = Readonly<Record<ResolvedActionState, RGBA>>
|
||||
export type FormfieldColor = StatefulColor
|
||||
|
||||
export type ResolvedThemeView = {
|
||||
readonly hue: Hue
|
||||
readonly source: (color: RGBA) => HueSource | undefined
|
||||
readonly increase: (color: RGBA, amount?: number) => RGBA
|
||||
readonly decrease: (color: RGBA, amount?: number) => RGBA
|
||||
readonly text: {
|
||||
|
||||
@@ -267,7 +267,7 @@ function neutralAnchors(theme: Theme, mode: "light" | "dark") {
|
||||
const light: { step: HueStep; color: RGBA }[] = [
|
||||
{ step: 100, color: theme.background },
|
||||
{ step: 200, color: theme.backgroundPanel },
|
||||
{ step: 300, color: theme.backgroundElement || theme.backgroundMenu },
|
||||
{ step: 300, color: theme.backgroundMenu },
|
||||
{ step: 700, color: theme.textMuted },
|
||||
{ step: 900, color: theme.text },
|
||||
]
|
||||
|
||||
@@ -17,8 +17,7 @@ type Active = ExportFormat | "debug" | "thinking" | "copy" | "export"
|
||||
|
||||
export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { themeV2: overlayTheme } = useTheme().contextual("overlay")
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
format: "markdown" as ExportFormat,
|
||||
debug: false,
|
||||
@@ -76,33 +75,25 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Export session
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={themeV2.text()}>Export as:</text>
|
||||
<text fg={theme.text}>Export as:</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={["markdown", "json"] as const}>
|
||||
{(format) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === format,
|
||||
selected: store.format === format,
|
||||
})}
|
||||
backgroundColor={store.format === format ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => selectFormat(format)}
|
||||
>
|
||||
<text
|
||||
fg={themeV2.text.formfield({
|
||||
focused: store.active === format,
|
||||
selected: store.format === format,
|
||||
})}
|
||||
>
|
||||
<text fg={store.format === format ? theme.text : theme.textMuted}>
|
||||
{store.format === format ? "◉" : "○"} {format === "markdown" ? "Markdown" : "JSON"}
|
||||
</text>
|
||||
</box>
|
||||
@@ -114,60 +105,43 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === "thinking",
|
||||
selected: store.thinking,
|
||||
})}
|
||||
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", "thinking")
|
||||
setStore("thinking", !store.thinking)
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
|
||||
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
|
||||
{store.thinking ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "thinking", selected: store.thinking })}>
|
||||
Include thinking
|
||||
</text>
|
||||
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={store.format === "json"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={themeV2.background.formfield({
|
||||
focused: store.active === "debug",
|
||||
selected: store.debug,
|
||||
})}
|
||||
backgroundColor={store.active === "debug" ? theme.backgroundElement : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", "debug")
|
||||
setStore("debug", !store.debug)
|
||||
}}
|
||||
>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
|
||||
{store.debug ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={themeV2.text.formfield({ focused: store.active === "debug", selected: store.debug })}>
|
||||
Events (debug)
|
||||
</text>
|
||||
<text fg={store.active === "debug" ? theme.primary : theme.textMuted}>{store.debug ? "[x]" : "[ ]"}</text>
|
||||
<text fg={store.active === "debug" ? theme.primary : theme.text}>Events (debug)</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
backgroundColor={overlayTheme.background()}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
onMouseUp={() => confirm("copy")}
|
||||
>
|
||||
<text fg={overlayTheme.text()}>Copy</text>
|
||||
<text fg={theme.text}>Copy</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
backgroundColor={themeV2.background.action({ focused: store.active === "export" })}
|
||||
onMouseUp={() => confirm("export")}
|
||||
>
|
||||
<text fg={themeV2.text.action({ focused: store.active === "export" })}>Export</text>
|
||||
<box paddingLeft={4} paddingRight={4} backgroundColor={theme.primary} onMouseUp={() => confirm("export")}>
|
||||
<text fg={theme.selectedListItemText}>Export</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -18,7 +18,7 @@ export type DialogPromptProps = {
|
||||
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [textareaTarget, setTextareaTarget] = createSignal<TextareaRenderable>()
|
||||
let textarea: TextareaRenderable
|
||||
@@ -74,10 +74,10 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={themeV2.text()}>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued()} onMouseUp={() => dialog.clear()}>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
@@ -91,20 +91,20 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
placeholderColor={themeV2.text.subdued()}
|
||||
textColor={themeV2.text.formfield({ disabled: props.busy })}
|
||||
focusedTextColor={themeV2.text.formfield({ disabled: props.busy })}
|
||||
cursorColor={props.busy ? themeV2.background.formfield("disabled") : themeV2.text()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={props.busy ? theme.textMuted : theme.text}
|
||||
focusedTextColor={props.busy ? theme.textMuted : theme.text}
|
||||
cursorColor={props.busy ? theme.backgroundElement : theme.text}
|
||||
/>
|
||||
<Show when={props.busy}>
|
||||
<Spinner color={themeV2.text.subdued()}>{props.busyText ?? "Working..."}</Spinner>
|
||||
<Spinner color={theme.textMuted}>{props.busyText ?? "Working..."}</Spinner>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<Show when={!props.busy} fallback={<text fg={themeV2.text.subdued()}>processing...</text>}>
|
||||
<Show when={!props.busy} fallback={<text fg={theme.textMuted}>processing...</text>}>
|
||||
<Show when={shortcuts.get("dialog.prompt.submit")}>
|
||||
<text fg={themeV2.text()}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: themeV2.text.subdued() }}>submit</span>
|
||||
<text fg={theme.text}>
|
||||
{shortcuts.get("dialog.prompt.submit")} <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Dialog(
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
||||
let dismiss = false
|
||||
@@ -59,7 +59,7 @@ export function Dialog(
|
||||
}}
|
||||
width={width()}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={themeV2.background()}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -14,7 +14,7 @@ type ToastInput = Omit<ToastOptions, "duration"> & { duration?: number }
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { themeV2 } = useTheme().contextual("overlay")
|
||||
const { theme, themeV2 } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
return (
|
||||
@@ -31,8 +31,8 @@ export function Toast() {
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={themeV2.background()}
|
||||
borderColor={themeV2.text.feedback[current().variant]()}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme[current().variant]}
|
||||
border={["left", "right"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
@@ -100,7 +100,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
server: { endpoint: { url: server.url.toString() }, connect: async (url) => ({ url }) },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: { sessionID: "dummy" },
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerProvider, decodeServerURLs, normalizeServerURL, serverName, useServer } from "../../src/context/server"
|
||||
|
||||
describe("TUI servers", () => {
|
||||
test("normalizes equivalent endpoint URLs", () => {
|
||||
expect(normalizeServerURL(" https://devbox.example/ ")).toBe("https://devbox.example")
|
||||
expect(normalizeServerURL("http://localhost:4096///")).toBe("http://localhost:4096")
|
||||
expect(normalizeServerURL("devbox.example:4096")).toBe("http://devbox.example:4096")
|
||||
expect(() => normalizeServerURL("https://user:secret@devbox.example")).toThrow("must not contain credentials")
|
||||
expect(() => normalizeServerURL("https://devbox.example?token=secret")).toThrow("query or fragment")
|
||||
})
|
||||
|
||||
test("labels loopback and remote servers", () => {
|
||||
expect(serverName("http://127.0.0.1:49374")).toBe("Local")
|
||||
expect(serverName("https://devbox.example:4096")).toBe("devbox.example:4096")
|
||||
})
|
||||
|
||||
test("decodes only valid persisted URLs", () => {
|
||||
expect(decodeServerURLs({ servers: ["https://devbox.example", 1, "ftp://nope"] })).toEqual([
|
||||
"https://devbox.example",
|
||||
])
|
||||
expect(decodeServerURLs(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("switches only after the next server is prepared", async () => {
|
||||
let server!: ReturnType<typeof useServer>
|
||||
const steps: string[] = []
|
||||
|
||||
function Harness() {
|
||||
server = useServer()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ServerProvider
|
||||
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||
urls={["https://devbox.example", "http://localhost:4096/"]}
|
||||
connect={async (url) => {
|
||||
steps.push(`connect:${url}`)
|
||||
return { url }
|
||||
}}
|
||||
prepare={async (endpoint) => {
|
||||
steps.push(`prepare:${endpoint.url}`)
|
||||
}}
|
||||
save={async () => {}}
|
||||
>
|
||||
<Harness />
|
||||
</ServerProvider>
|
||||
))
|
||||
try {
|
||||
expect(server.list().map((item) => item.url)).toEqual(["http://localhost:4096", "https://devbox.example"])
|
||||
await server.select("https://devbox.example")
|
||||
expect(steps).toEqual(["connect:https://devbox.example", "prepare:https://devbox.example"])
|
||||
expect(server.current.url).toBe("https://devbox.example")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("persists only the normalized URL after a successful add", async () => {
|
||||
let server!: ReturnType<typeof useServer>
|
||||
const writes: string[][] = []
|
||||
|
||||
function Harness() {
|
||||
server = useServer()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ServerProvider
|
||||
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||
urls={[]}
|
||||
connect={async (url) => ({
|
||||
url,
|
||||
auth: { type: "basic", username: "opencode", password: "secret" },
|
||||
})}
|
||||
prepare={async () => {}}
|
||||
save={async (urls) => void writes.push(urls)}
|
||||
>
|
||||
<Harness />
|
||||
</ServerProvider>
|
||||
))
|
||||
try {
|
||||
await server.add("devbox.example:4096/")
|
||||
expect(writes).toEqual([["http://devbox.example:4096"]])
|
||||
expect(JSON.stringify(writes)).not.toContain("secret")
|
||||
expect(server.current.endpoint.auth?.password).toBe("secret")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the current server when preparing a new endpoint fails", async () => {
|
||||
let server!: ReturnType<typeof useServer>
|
||||
let saved = false
|
||||
|
||||
function Harness() {
|
||||
server = useServer()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ServerProvider
|
||||
initial={{ endpoint: { url: "http://localhost:4096" } }}
|
||||
urls={[]}
|
||||
connect={async (url) => ({ url })}
|
||||
prepare={async () => {
|
||||
throw new Error("unreachable")
|
||||
}}
|
||||
save={async () => {
|
||||
saved = true
|
||||
}}
|
||||
>
|
||||
<Harness />
|
||||
</ServerProvider>
|
||||
))
|
||||
try {
|
||||
await expect(server.add("https://devbox.example")).rejects.toThrow("unreachable")
|
||||
expect(server.current.url).toBe("http://localhost:4096")
|
||||
expect(saved).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user