mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee66e11c27 | |||
| 33f1b269e9 | |||
| 0d68b0bb20 | |||
| 4e56998d3c | |||
| d625bc86fc | |||
| 5f437a09b0 | |||
| 7d4496eafc | |||
| 1f2de535aa | |||
| 529d55b1c3 | |||
| deb5b144c3 | |||
| cd3cca0006 | |||
| 08a7080e11 | |||
| f2579c41b6 |
@@ -1,6 +1,6 @@
|
|||||||
# LLM Provider Parity Status
|
# LLM Provider Parity Status
|
||||||
|
|
||||||
Last reviewed: 2026-07-16
|
Last reviewed: 2026-07-17
|
||||||
|
|
||||||
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.
|
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 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 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. |
|
| 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. | No named compatible 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 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. |
|
| 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. |
|
| 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. |
|
| 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,6 +161,18 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
|||||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.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",
|
id: "mistral",
|
||||||
label: "Mistral",
|
label: "Mistral",
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const OpenAIChatMessage = Schema.Union([
|
|||||||
content: Schema.NullOr(Schema.String),
|
content: Schema.NullOr(Schema.String),
|
||||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||||
reasoning_content: Schema.optional(Schema.String),
|
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 }),
|
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
@@ -145,6 +147,8 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
|||||||
const OpenAIChatDelta = Schema.Struct({
|
const OpenAIChatDelta = Schema.Struct({
|
||||||
content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
reasoning_content: optionalNull(Schema.String),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
|
reasoning: optionalNull(Schema.String),
|
||||||
|
reasoning_text: optionalNull(Schema.String),
|
||||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,6 +170,7 @@ export interface ParserState {
|
|||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReason
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -208,6 +213,12 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
|||||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
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 lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
@@ -248,14 +259,20 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const text = reasoning.map((part) => part.text).join("")
|
||||||
|
const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
|
||||||
return {
|
return {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content:
|
reasoning_content:
|
||||||
reasoning.length > 0
|
reasoning.length === 0
|
||||||
? reasoning.map((part) => part.text).join("")
|
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||||
: 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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -400,6 +417,12 @@ 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) =>
|
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
@@ -412,8 +435,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
if (delta?.reasoning_content)
|
const reasoning = reasoningDelta(delta)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||||
|
if (reasoning)
|
||||||
|
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
|
||||||
|
openai: { reasoningField: reasoningField ?? reasoning.field },
|
||||||
|
})
|
||||||
|
|
||||||
if (delta?.content) {
|
if (delta?.content) {
|
||||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||||
@@ -450,6 +477,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
usage,
|
usage,
|
||||||
finishReason,
|
finishReason,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
reasoningField,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -482,7 +510,12 @@ export const protocol = Protocol.make({
|
|||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
initial: () => ({
|
||||||
|
tools: ToolStream.empty<number>(),
|
||||||
|
toolCallEvents: [],
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
reasoningField: undefined,
|
||||||
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
},
|
},
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"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,4 +1,5 @@
|
|||||||
import * as Anthropic from "../../src/providers/anthropic"
|
import * as Anthropic from "../../src/providers/anthropic"
|
||||||
|
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
|
||||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||||
import * as Google from "../../src/providers/google"
|
import * as Google from "../../src/providers/google"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
@@ -17,6 +18,11 @@ const anthropic = Anthropic.configure({
|
|||||||
})
|
})
|
||||||
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
||||||
const anthropicOpus = anthropic.model("claude-opus-4-7")
|
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 google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
|
||||||
const gemini = google.model("gemini-2.5-flash")
|
const gemini = google.model("gemini-2.5-flash")
|
||||||
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||||
@@ -108,6 +114,15 @@ describeRecordedGoldenScenarios([
|
|||||||
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
|
{ 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",
|
name: "Gemini 2.5 Flash",
|
||||||
prefix: "gemini",
|
prefix: "gemini",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,29 +540,33 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
|
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||||
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
|
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: { content: "Hello" } }] },
|
||||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
|
||||||
|
|
||||||
expect(response.reasoning).toBe("thinking")
|
expect(response.reasoning).toBe("thinking")
|
||||||
expect(response.text).toBe("Hello")
|
expect(response.text).toBe("Hello")
|
||||||
expect(response.events).toMatchObject([
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
{ type: "step-start", index: 0 },
|
openai: { reasoningField: field },
|
||||||
{ type: "reasoning-start", id: "reasoning-0" },
|
})
|
||||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
|
||||||
{ type: "reasoning-end", id: "reasoning-0" },
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
{ type: "text-start", id: "text-0" },
|
LLM.request({ model, messages: [response.message] }),
|
||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
)
|
||||||
{ type: "text-end", id: "text-0" },
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||||
{ type: "step-finish", index: 0, reason: "stop" },
|
}
|
||||||
{ type: "finish", reason: "stop" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const ServerParams = {
|
|||||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||||
Flag.optional,
|
Flag.optional,
|
||||||
),
|
),
|
||||||
|
remote: Flag.string("remote").pipe(
|
||||||
|
Flag.withAlias("r"),
|
||||||
|
Flag.withDescription("Connect to a saved remote server"),
|
||||||
|
Flag.optional,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||||
@@ -201,6 +206,27 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Spec.make("server", {
|
||||||
|
description: "Manage saved server connections",
|
||||||
|
commands: [
|
||||||
|
Spec.make("list", { description: "List saved server connections" }),
|
||||||
|
Spec.make("add", {
|
||||||
|
description: "Save a server connection (and OPENCODE_PASSWORD when set)",
|
||||||
|
params: {
|
||||||
|
name: Argument.string("name").pipe(Argument.withDescription("Name for the saved server")),
|
||||||
|
url: Argument.string("url").pipe(Argument.withDescription("Server URL")),
|
||||||
|
username: Flag.string("username").pipe(
|
||||||
|
Flag.withDescription("Basic authentication username"),
|
||||||
|
Flag.optional,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Spec.make("remove", {
|
||||||
|
description: "Remove a saved server connection",
|
||||||
|
params: { name: Argument.string("name").pipe(Argument.withDescription("Saved server name")) },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
Spec.make("service", {
|
Spec.make("service", {
|
||||||
description: "Manage the background server",
|
description: "Manage the background server",
|
||||||
commands: [
|
commands: [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default Runtime.handler(
|
|||||||
Effect.fn("cli.api")(function* (input) {
|
Effect.fn("cli.api")(function* (input) {
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
mismatch: "ignore",
|
mismatch: "ignore",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
onStart: (reason, previousVersion) => {
|
onStart: (reason, previousVersion) => {
|
||||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
|||||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
const server = yield* ServerConnection.resolve({
|
||||||
|
server: serverURL,
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
|
standalone: input.standalone,
|
||||||
|
})
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default Runtime.handler(Commands.commands.run, (input) =>
|
|||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
remote: Option.getOrUndefined(input.remote),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
})
|
})
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Effect, Option, Redacted } from "effect"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Config } from "../../../config"
|
||||||
|
import { Env } from "../../../env"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.server.commands.add,
|
||||||
|
Effect.fn("cli.server.add")(function* (input) {
|
||||||
|
if (!URL.canParse(input.url)) return yield* Effect.fail(new Error(`Invalid server URL: ${input.url}`))
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const password = yield* Env.password
|
||||||
|
yield* config.update((draft) => {
|
||||||
|
draft.servers ??= {}
|
||||||
|
draft.servers[input.name] = {
|
||||||
|
url: input.url,
|
||||||
|
username: Option.getOrUndefined(input.username),
|
||||||
|
password: password ? Redacted.value(password) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
process.stdout.write(`Saved server ${input.name}\n`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Config } from "../../../config"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.server.commands.list,
|
||||||
|
Effect.fn("cli.server.list")(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const servers = Object.entries((yield* config.get()).servers ?? {})
|
||||||
|
if (!servers.length) {
|
||||||
|
process.stdout.write("No saved servers\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.stdout.write(
|
||||||
|
servers
|
||||||
|
.map(([name, server]) => `${name}\t${server.url}${server.username ? `\t${server.username}` : ""}`)
|
||||||
|
.join("\n") + "\n",
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Config } from "../../../config"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.server.commands.remove,
|
||||||
|
Effect.fn("cli.server.remove")(function* (input) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
if ((yield* config.get()).servers?.[input.name] === undefined)
|
||||||
|
return yield* Effect.fail(new Error(`Saved server "${input.name}" not found`))
|
||||||
|
yield* config.update((draft) => {
|
||||||
|
if (!draft.servers) return
|
||||||
|
delete draft.servers[input.name]
|
||||||
|
if (!Object.keys(draft.servers).length) delete draft.servers
|
||||||
|
})
|
||||||
|
process.stdout.write(`Removed server ${input.name}\n`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Config } from "@opencode-ai/tui/config"
|
import { Config } from "@opencode-ai/tui/config"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
|
||||||
export const Info = Schema.Struct({ ...Config.Info.fields })
|
export const Server = Schema.Struct({
|
||||||
|
url: Schema.String,
|
||||||
|
username: Schema.optional(Schema.String),
|
||||||
|
password: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
export type Server = Schema.Schema.Type<typeof Server>
|
||||||
|
|
||||||
|
export const Info = Schema.Struct({
|
||||||
|
...Config.Info.fields,
|
||||||
|
servers: Schema.optional(Schema.Record(Schema.String, Server)),
|
||||||
|
})
|
||||||
export type Info = Schema.Schema.Type<typeof Info>
|
export type Info = Schema.Schema.Type<typeof Info>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
server: {
|
||||||
|
list: () => import("./commands/handlers/server/list"),
|
||||||
|
add: () => import("./commands/handlers/server/add"),
|
||||||
|
remove: () => import("./commands/handlers/server/remove"),
|
||||||
|
},
|
||||||
service: {
|
service: {
|
||||||
start: () => import("./commands/handlers/service/start"),
|
start: () => import("./commands/handlers/service/start"),
|
||||||
restart: () => import("./commands/handlers/service/restart"),
|
restart: () => import("./commands/handlers/service/restart"),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
}
|
}
|
||||||
const password =
|
const password =
|
||||||
options.mode === "service"
|
options.mode === "service"
|
||||||
? yield* ServiceConfig.password()
|
? config.password || randomBytes(32).toString("base64url")
|
||||||
: environmentPassword
|
: environmentPassword
|
||||||
? Redacted.value(environmentPassword)
|
? Redacted.value(environmentPassword)
|
||||||
: randomBytes(32).toString("base64url")
|
: randomBytes(32).toString("base64url")
|
||||||
@@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
serviceOptions === undefined
|
serviceOptions === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
onListen: (address, shutdown) => register(address, password, instanceID, serviceOptions.file, shutdown),
|
onListen: (address, shutdown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!config.password) yield* ServiceConfig.password(password)
|
||||||
|
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||||
@@ -154,8 +158,8 @@ const register = Effect.fnUntraced(function* (
|
|||||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||||
Effect.filterOrFail((value) => value !== undefined),
|
Effect.filterOrFail((value) => value !== undefined),
|
||||||
Effect.retry(Schedule.max([Schedule.spaced("100 millis"), Schedule.recurs(60)])),
|
Effect.retry(Schedule.spaced("100 millis")),
|
||||||
Effect.option,
|
Effect.timeoutOption("15 seconds"),
|
||||||
)
|
)
|
||||||
return Option.isSome(found)
|
return Option.isSome(found)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/
|
|||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { Effect, Redacted } from "effect"
|
import { Effect, Redacted } from "effect"
|
||||||
|
import { Config } from "../config"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import { ServiceConfig } from "./service-config"
|
import { ServiceConfig } from "./service-config"
|
||||||
import { Standalone } from "./standalone"
|
import { Standalone } from "./standalone"
|
||||||
|
|
||||||
export type Args = {
|
export type Args = {
|
||||||
readonly server?: string
|
readonly server?: string
|
||||||
|
readonly remote?: string
|
||||||
readonly standalone?: boolean
|
readonly standalone?: boolean
|
||||||
readonly mismatch?: "replace" | "ignore" | "error"
|
readonly mismatch?: "replace" | "ignore" | "error"
|
||||||
readonly onStart?: EnsureOptions["onStart"]
|
readonly onStart?: EnsureOptions["onStart"]
|
||||||
@@ -19,13 +21,28 @@ export type Resolved = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||||
if (args.server !== undefined && args.standalone)
|
if (args.server !== undefined && args.remote !== undefined)
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
return yield* Effect.fail(new Error("--server and --remote cannot be combined"))
|
||||||
if (args.server !== undefined) {
|
if ((args.server !== undefined || args.remote !== undefined) && args.standalone)
|
||||||
const password = yield* Env.password
|
return yield* Effect.fail(new Error("--server, --remote, and --standalone cannot be combined"))
|
||||||
|
if (args.server !== undefined || args.remote !== undefined) {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const profile = args.remote === undefined ? undefined : (yield* config.get()).servers?.[args.remote]
|
||||||
|
if (args.remote !== undefined && profile === undefined)
|
||||||
|
return yield* Effect.fail(new Error(`Saved server "${args.remote}" not found`))
|
||||||
|
const environmentPassword = yield* Env.password
|
||||||
|
const password = environmentPassword ? Redacted.value(environmentPassword) : profile?.password
|
||||||
|
const url = profile?.url ?? args.server
|
||||||
|
if (url === undefined) return yield* Effect.fail(new Error("Missing server URL"))
|
||||||
const endpoint = {
|
const endpoint = {
|
||||||
url: args.server,
|
url,
|
||||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
auth: password
|
||||||
|
? {
|
||||||
|
type: "basic" as const,
|
||||||
|
username: profile?.username ?? "opencode",
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
} satisfies Endpoint
|
} satisfies Endpoint
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const health = yield* Effect.tryPromise({
|
const health = yield* Effect.tryPromise({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Effect, FileSystem, Scope } from "effect"
|
|||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { Config } from "../src/config"
|
||||||
import { ServerConnection } from "../src/services/server-connection"
|
import { ServerConnection } from "../src/services/server-connection"
|
||||||
import { ServiceConfig } from "../src/services/service-config"
|
import { ServiceConfig } from "../src/services/service-config"
|
||||||
|
|
||||||
@@ -24,8 +25,17 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||||||
})
|
})
|
||||||
const registration = path.join(root, "state", ServiceConfig.filename())
|
const registration = path.join(root, "state", ServiceConfig.filename())
|
||||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
const runPromise = <A, E>(
|
||||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope | Config.Service>,
|
||||||
|
) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
effect.pipe(
|
||||||
|
Effect.provide(Config.layer),
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
Effect.scoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||||
@@ -50,8 +60,44 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||||||
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
||||||
expect(explicit.endpoint.url).toBe(server.url.toString())
|
expect(explicit.endpoint.url).toBe(server.url.toString())
|
||||||
expect(explicit.service).toBeUndefined()
|
expect(explicit.service).toBeUndefined()
|
||||||
|
|
||||||
|
await runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
yield* config.update((draft) => {
|
||||||
|
draft.servers = {
|
||||||
|
mac: { url: server.url.toString(), username: "ryan", password: "secret" },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const saved = await runPromise(ServerConnection.resolve({ remote: "mac" }))
|
||||||
|
expect(saved.endpoint).toEqual({
|
||||||
|
url: server.url.toString(),
|
||||||
|
auth: { type: "basic", username: "ryan", password: "secret" },
|
||||||
|
})
|
||||||
|
expect(saved.service).toBeUndefined()
|
||||||
} finally {
|
} finally {
|
||||||
await server.stop(true)
|
await server.stop(true)
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reports an unknown saved server", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-"))
|
||||||
|
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
Effect.flip(ServerConnection.resolve({ remote: "missing" })).pipe(
|
||||||
|
Effect.provide(Config.layer),
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provide(NodeFileSystem.layer),
|
||||||
|
Effect.scoped,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(result.message).toBe('Saved server "missing" not found')
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(directories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function cli(
|
||||||
|
args: string[],
|
||||||
|
options: { directory?: string; environment?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
const directory = options.directory ?? (await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-")))
|
||||||
|
if (!options.directory) directories.push(directory)
|
||||||
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||||
|
cwd: path.join(import.meta.dir, ".."),
|
||||||
|
env: { ...process.env, OPENCODE_CONFIG_DIR: directory, ...options.environment },
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
const [stdout, stderr, exitCode] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
child.exited,
|
||||||
|
])
|
||||||
|
return { directory, stdout, stderr, exitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("adds, lists, and removes a saved server", async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-profile-shared-"))
|
||||||
|
directories.push(directory)
|
||||||
|
|
||||||
|
const added = await cli(["server", "add", "mac", "http://127.0.0.1:4096", "--username", "ryan"], {
|
||||||
|
directory,
|
||||||
|
environment: { OPENCODE_PASSWORD: "secret" },
|
||||||
|
})
|
||||||
|
expect(added).toMatchObject({ exitCode: 0, stdout: "Saved server mac\n" })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).json()).toMatchObject({
|
||||||
|
servers: { mac: { url: "http://127.0.0.1:4096", username: "ryan", password: "secret" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const listed = await cli(["server", "list"], { directory })
|
||||||
|
expect(listed).toMatchObject({ exitCode: 0, stdout: "mac\thttp://127.0.0.1:4096\tryan\n" })
|
||||||
|
expect(listed.stdout).not.toContain("secret")
|
||||||
|
|
||||||
|
const removed = await cli(["server", "remove", "mac"], { directory })
|
||||||
|
expect(removed).toMatchObject({ exitCode: 0, stdout: "Removed server mac\n" })
|
||||||
|
expect(await Bun.file(path.join(directory, "cli.json")).json()).not.toHaveProperty("servers")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exposes a remote shorthand without replacing the session shorthand", async () => {
|
||||||
|
const result = await cli(["--help"])
|
||||||
|
expect(result.exitCode).toBe(0)
|
||||||
|
expect(result.stdout).toContain("--remote, -r string")
|
||||||
|
expect(result.stdout).toContain("--session, -s string")
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
|||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { Project } from "@opencode-ai/core/project"
|
import { Project } from "@opencode-ai/core/project"
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
@@ -212,9 +213,10 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
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 registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
const port = await availablePort()
|
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.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
await fs.writeFile(config, JSON.stringify({ port }))
|
||||||
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
|
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await waitForInfo(registration)
|
const info = await waitForInfo(registration)
|
||||||
@@ -225,8 +227,18 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(exited).toEqual(losers.map(() => true))
|
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(winner?.exitCode).toBe(null)
|
||||||
expect(new URL(info.url).port).toBe(String(port))
|
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 Bun.file(registration + ".lock").exists()).toBe(false)
|
||||||
expect(
|
expect(
|
||||||
await fetch(new URL("/api/health", info.url), {
|
await fetch(new URL("/api/health", info.url), {
|
||||||
@@ -244,6 +256,7 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
Bun.sleep(10_000).then(() => false),
|
Bun.sleep(10_000).then(() => false),
|
||||||
])
|
])
|
||||||
expect(contenderExited).toBe(true)
|
expect(contenderExited).toBe(true)
|
||||||
|
expect(contender.exitCode).toBe(0)
|
||||||
expect((await waitForInfo(registration)).id).toBe(info.id)
|
expect((await waitForInfo(registration)).id).toBe(info.id)
|
||||||
} finally {
|
} finally {
|
||||||
contender.kill("SIGTERM")
|
contender.kill("SIGTERM")
|
||||||
@@ -265,15 +278,12 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
||||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
await winner?.exited
|
await winner?.exited
|
||||||
|
expect(await Bun.file(registration).exists()).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
processes.forEach((process) => process.kill("SIGTERM"))
|
processes.forEach((process) => process.kill("SIGTERM"))
|
||||||
await Promise.all(processes.map((process) => process.exited))
|
await Promise.all(processes.map((process) => process.exited))
|
||||||
try {
|
|
||||||
expect(await Bun.file(registration).exists()).toBe(false)
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, 120_000)
|
}, 120_000)
|
||||||
|
|
||||||
test("configured managed service port overrides the channel default", async () => {
|
test("configured managed service port overrides the channel default", async () => {
|
||||||
@@ -281,8 +291,9 @@ test("configured managed service port overrides the channel default", async () =
|
|||||||
const port = await availablePort()
|
const port = await availablePort()
|
||||||
const env = serviceEnv(root)
|
const env = serviceEnv(root)
|
||||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
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.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
await fs.writeFile(config, JSON.stringify({ port, password: "" }))
|
||||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
env,
|
env,
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -291,6 +302,8 @@ test("configured managed service port overrides the channel default", async () =
|
|||||||
try {
|
try {
|
||||||
const info = await waitForInfo(registration)
|
const info = await waitForInfo(registration)
|
||||||
expect(new URL(info.url).port).toBe(String(port))
|
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 Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
await owner.exited
|
await owner.exited
|
||||||
} finally {
|
} finally {
|
||||||
@@ -302,7 +315,7 @@ test("configured managed service port overrides the channel default", async () =
|
|||||||
|
|
||||||
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||||
const listener = Bun.serve({ port: 0, fetch: () => new Response("unrelated") })
|
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||||
const port = listener.port
|
const port = listener.port
|
||||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
@@ -326,6 +339,109 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
|
|||||||
}
|
}
|
||||||
}, 30_000)
|
}, 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",
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
test("stale dead registration is replaced after binding the selected port", async () => {
|
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 root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
|
||||||
const port = await availablePort()
|
const port = await availablePort()
|
||||||
@@ -375,10 +491,12 @@ test("a failed service stays registered and owns the selected port until stopped
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await waitForInfo(registration)
|
const info = await waitForInfo(registration)
|
||||||
|
await waitForFailed(info)
|
||||||
expect(owner.exitCode).toBe(null)
|
expect(owner.exitCode).toBe(null)
|
||||||
|
|
||||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
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(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((await waitForInfo(registration)).id).toBe(info.id)
|
||||||
expect(owner.exitCode).toBe(null)
|
expect(owner.exitCode).toBe(null)
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ultimate source of truth.
|
|||||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
- [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] 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
|
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
|
||||||
arguments remain subject to their schema and the outbound-handling gap listed below.
|
arguments follow JSON serialization semantics before their schema applies (see the tools section).
|
||||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
|
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
|
||||||
- [x] Tool calls through the host-provided `tools` tree only.
|
- [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
|
- [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] Expression and block function bodies.
|
||||||
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
|
- [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.
|
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
|
||||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, and URI helpers as callbacks.
|
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
|
||||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
- [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
|
`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
|
does not consume are ignored, like JS; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
||||||
@@ -157,8 +157,11 @@ ultimate source of truth.
|
|||||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
- [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.
|
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] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||||
- [ ] Reject `undefined` and non-finite numbers in outbound tool arguments before render-only and OpenAPI tools run;
|
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
|
||||||
retain null normalization for program results and JSON serialization.
|
`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`.
|
||||||
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
|
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
|
||||||
|
|
||||||
## Objects and properties
|
## Objects and properties
|
||||||
@@ -210,9 +213,12 @@ ultimate source of truth.
|
|||||||
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
- [x] `localeCompare`; locale and options arguments are currently ignored.
|
||||||
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
- [x] `toString`, `length`, numeric indexing, spread, and `for...of` by Unicode code point.
|
||||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||||
- [ ] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` currently
|
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||||
reject instead of coercing.
|
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||||
- [ ] Native no-argument parity for `match()` and `search()`.
|
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.
|
||||||
|
|
||||||
## Numbers and Math
|
## Numbers and Math
|
||||||
|
|
||||||
@@ -226,13 +232,16 @@ ultimate source of truth.
|
|||||||
- [x] Math methods: `random`, `max`, `min`, `abs`, `acos`, `acosh`, `asin`, `asinh`, `atan`, `atan2`, `atanh`,
|
- [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`,
|
`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`.
|
`tan`, `tanh`, `log`, `log2`, `log10`, `log1p`, `exp`, `expm1`, `f16round`, `fround`, `clz32`, and `imul`.
|
||||||
- [ ] Native zero-argument behavior for `Number()` and `String()`; they currently do not produce `0` and `""`.
|
- [x] Native zero-argument behavior for `Number()` and `String()`: they produce `0` and `""`, while
|
||||||
- [ ] `++` and `--` must use CodeMode numeric coercion and reject opaque runtime references; they currently call host
|
`Number(undefined)` stays `NaN` and `String(undefined)` stays `"undefined"`.
|
||||||
`Number(...)` directly.
|
- [x] `++` and `--` use CodeMode numeric coercion (numeric strings increment, plain data objects become `NaN`, Dates
|
||||||
- [ ] Unknown static members must read as `undefined` for feature detection; some currently appear callable or throw
|
use their epoch time) and reject opaque runtime references as data errors.
|
||||||
during property access.
|
- [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.
|
||||||
- [ ] `Math.sumPrecise`.
|
- [ ] `Math.sumPrecise`.
|
||||||
- [ ] Global coercing `isFinite` and `isNaN`.
|
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||||
|
|
||||||
## JSON and console
|
## JSON and console
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
|
|||||||
message = (value as { message: string }).message
|
message = (value as { message: string }).message
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
|
||||||
} catch {
|
} catch {
|
||||||
message = String(value)
|
message = String(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
|
|||||||
logs,
|
logs,
|
||||||
)
|
)
|
||||||
const value = yield* interpreter.run(program)
|
const value = yield* interpreter.run(program)
|
||||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
const result = copyOut(copyIn(value, "Execution result"), "nullify") as DataValue
|
||||||
returned = { value: result, promises }
|
returned = { value: result, promises }
|
||||||
const warnings = yield* promises.interrupt()
|
const warnings = yield* promises.interrupt()
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
PromiseNamespace,
|
PromiseNamespace,
|
||||||
UriFunction,
|
UriFunction,
|
||||||
} from "./model.js"
|
} from "./model.js"
|
||||||
import { rejectCircularInsertion, typeofValue } from "./references.js"
|
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||||
import {
|
import {
|
||||||
CodeModeDate,
|
CodeModeDate,
|
||||||
@@ -137,21 +137,31 @@ export const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unkno
|
|||||||
return invokeJsonMethod(ref.name, args, node)
|
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 => {
|
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
const str = (index: number): string => {
|
// Coerce arguments like native JS; opaque runtime references still reject.
|
||||||
const arg = args[index]
|
const str = (index: number): string => coerceToString(requireDataArgument(name, index, args[index], node))
|
||||||
if (typeof arg !== "string")
|
const num = (index: number): number => coerceToNumber(requireDataArgument(name, index, args[index], node))
|
||||||
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 optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(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
|
let result: unknown
|
||||||
switch (name) {
|
switch (name) {
|
||||||
@@ -187,8 +197,11 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "split": {
|
case "split": {
|
||||||
if (args.length === 0) {
|
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||||
result = [value]
|
// unless the limit truncates to zero.
|
||||||
|
if (args[0] === undefined) {
|
||||||
|
const requestedLimit = optNum(1)
|
||||||
|
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (args[0] instanceof CodeModeRegExp) {
|
if (args[0] instanceof CodeModeRegExp) {
|
||||||
@@ -203,12 +216,15 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
|||||||
result = value.slice(optNum(0), optNum(1))
|
result = value.slice(optNum(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "includes":
|
case "includes":
|
||||||
|
rejectRegex()
|
||||||
result = value.includes(str(0), optNum(1))
|
result = value.includes(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "startsWith":
|
case "startsWith":
|
||||||
|
rejectRegex()
|
||||||
result = value.startsWith(str(0), optNum(1))
|
result = value.startsWith(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "endsWith":
|
case "endsWith":
|
||||||
|
rejectRegex()
|
||||||
result = value.endsWith(str(0), optNum(1))
|
result = value.endsWith(str(0), optNum(1))
|
||||||
break
|
break
|
||||||
case "indexOf":
|
case "indexOf":
|
||||||
@@ -263,7 +279,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
|||||||
case "repeat": {
|
case "repeat": {
|
||||||
const count = num(0)
|
const count = num(0)
|
||||||
if (!Number.isFinite(count) || count < 0)
|
if (!Number.isFinite(count) || count < 0)
|
||||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node).as("RangeError")
|
||||||
result = value.repeat(count)
|
result = value.repeat(count)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -301,6 +317,8 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
|
|||||||
return boundedData(result, `String.${name} result`)
|
return boundedData(result, `String.${name} result`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const arrayStatics = new Set(["isArray", "of", "from"])
|
||||||
|
|
||||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "isArray":
|
case "isArray":
|
||||||
@@ -400,11 +418,9 @@ const invokeStringReplacer = <R>(
|
|||||||
if (name === "replace") value.replace(pattern.regex, collect)
|
if (name === "replace") value.replace(pattern.regex, collect)
|
||||||
else value.replaceAll(pattern.regex, collect)
|
else value.replaceAll(pattern.regex, collect)
|
||||||
} else {
|
} else {
|
||||||
if (typeof pattern !== "string") {
|
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
|
||||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
if (name === "replace") value.replace(search, collect)
|
||||||
}
|
else value.replaceAll(search, collect)
|
||||||
if (name === "replace") value.replace(pattern, collect)
|
|
||||||
else value.replaceAll(pattern, collect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class GlobalMethodReference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CoercionFunction {
|
export class CoercionFunction {
|
||||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UriFunction {
|
export class UriFunction {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ErrorConstructorReference,
|
ErrorConstructorReference,
|
||||||
GlobalMethodReference,
|
GlobalMethodReference,
|
||||||
GlobalNamespace,
|
GlobalNamespace,
|
||||||
|
type GlobalNamespaceName,
|
||||||
getArray,
|
getArray,
|
||||||
getBoolean,
|
getBoolean,
|
||||||
getNode,
|
getNode,
|
||||||
@@ -34,7 +35,7 @@ import {
|
|||||||
UriFunction,
|
UriFunction,
|
||||||
} from "./model.js"
|
} from "./model.js"
|
||||||
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
import { caughtErrorValue, constructErrorValue } from "./errors.js"
|
||||||
import { type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
import { arrayStatics, type CallbackRunner, invokeArrayFrom, invokeGlobalMethod, invokeIntrinsic } from "./methods.js"
|
||||||
import {
|
import {
|
||||||
constructPromise,
|
constructPromise,
|
||||||
invokePromiseInstanceMethod,
|
invokePromiseInstanceMethod,
|
||||||
@@ -46,10 +47,11 @@ import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, t
|
|||||||
import { ScopeStack } from "./scope.js"
|
import { ScopeStack } from "./scope.js"
|
||||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||||
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
import { consoleMethods, formatConsoleMessage } from "../stdlib/console.js"
|
||||||
import { dateMethods } from "../stdlib/date.js"
|
import { dateMethods, dateStatics } from "../stdlib/date.js"
|
||||||
import { mathConstants } from "../stdlib/math.js"
|
import { jsonStatics } from "../stdlib/json.js"
|
||||||
|
import { mathConstants, mathMethods } from "../stdlib/math.js"
|
||||||
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
import { numberConstants, numberMethods, numberStatics } from "../stdlib/number.js"
|
||||||
import { objectMethodsPreservingIdentity } from "../stdlib/object.js"
|
import { objectMethodsPreservingIdentity, objectStatics } from "../stdlib/object.js"
|
||||||
import { promiseStatics } from "../stdlib/promise.js"
|
import { promiseStatics } from "../stdlib/promise.js"
|
||||||
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
import { escapeRegexHint, regexpMethods, regexpProperties, regexFailureReason } from "../stdlib/regexp.js"
|
||||||
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
import { stringMethods, stringStatics } from "../stdlib/string.js"
|
||||||
@@ -57,6 +59,7 @@ import {
|
|||||||
urlMethods,
|
urlMethods,
|
||||||
urlProperties,
|
urlProperties,
|
||||||
urlSearchParamsMethods,
|
urlSearchParamsMethods,
|
||||||
|
urlStatics,
|
||||||
urlWritableProperties,
|
urlWritableProperties,
|
||||||
invokeUriFunction,
|
invokeUriFunction,
|
||||||
uriArgument,
|
uriArgument,
|
||||||
@@ -83,6 +86,32 @@ import {
|
|||||||
CodeModeURLSearchParams,
|
CodeModeURLSearchParams,
|
||||||
} from "../values.js"
|
} 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 => {
|
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||||
if (rhs instanceof ErrorConstructorReference) {
|
if (rhs instanceof ErrorConstructorReference) {
|
||||||
const brand = errorBrandName(lhs)
|
const brand = errorBrandName(lhs)
|
||||||
@@ -199,6 +228,8 @@ export class Interpreter<R> {
|
|||||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
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("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||||
@@ -1454,10 +1485,23 @@ export class Interpreter<R> {
|
|||||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
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") {
|
if (argument.type === "Identifier") {
|
||||||
return Effect.sync(() => {
|
return Effect.sync(() => {
|
||||||
const name = getString(argument, "name")
|
const name = getString(argument, "name")
|
||||||
const current = Number(this.scopes.get(name, argument))
|
const current = operand(this.scopes.get(name, argument))
|
||||||
const next = current + increment
|
const next = current + increment
|
||||||
this.scopes.set(name, next, argument)
|
this.scopes.set(name, next, argument)
|
||||||
return prefix ? next : current
|
return prefix ? next : current
|
||||||
@@ -1466,7 +1510,7 @@ export class Interpreter<R> {
|
|||||||
|
|
||||||
if (argument.type === "MemberExpression") {
|
if (argument.type === "MemberExpression") {
|
||||||
return this.modifyMember(argument, (current) => {
|
return this.modifyMember(argument, (current) => {
|
||||||
const value = Number(current)
|
const value = operand(current)
|
||||||
const next = value + increment
|
const next = value + increment
|
||||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||||
})
|
})
|
||||||
@@ -1563,6 +1607,9 @@ export class Interpreter<R> {
|
|||||||
callable.settle(args[0])
|
callable.settle(args[0])
|
||||||
return undefined
|
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)
|
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1833,17 +1880,19 @@ export class Interpreter<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof GlobalNamespace) {
|
if (objectValue instanceof GlobalNamespace) {
|
||||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
if (typeof key === "string" && isBlockedMember(key)) {
|
||||||
throw new InterpreterRuntimeError(
|
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available in CodeMode.`, propertyNode)
|
||||||
`${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)) {
|
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||||
}
|
}
|
||||||
|
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||||
return new GlobalMethodReference(objectValue.name, key)
|
return new GlobalMethodReference(objectValue.name, key)
|
||||||
}
|
}
|
||||||
|
// Unknown static members read as undefined so feature detection works like native JS.
|
||||||
|
return new ComputedValue(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof objectValue === "string") {
|
if (typeof objectValue === "string") {
|
||||||
if (key === "length") return new ComputedValue(objectValue.length)
|
if (key === "length") return new ComputedValue(objectValue.length)
|
||||||
@@ -1858,12 +1907,21 @@ export class Interpreter<R> {
|
|||||||
return new ComputedValue(undefined)
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
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.name === "Number" && numberConstants.has(key)) {
|
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||||
return new ComputedValue((Number as unknown as Record<string, number>)[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 === "Number" && numberStatics.has(key)) {
|
||||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
return new GlobalMethodReference("Number", key)
|
||||||
|
}
|
||||||
|
if (objectValue.name === "String" && stringStatics.has(key)) {
|
||||||
|
return new GlobalMethodReference("String", key)
|
||||||
|
}
|
||||||
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (objectValue instanceof CodeModeDate) {
|
if (objectValue instanceof CodeModeDate) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
|
|||||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
||||||
if (value === undefined) return undefined
|
if (value === undefined) return undefined
|
||||||
if (containsRuntimeReference(value)) return undefined
|
if (containsRuntimeReference(value)) return undefined
|
||||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
const columns = copyOut(copyIn(value, "console.table columns"), "nullify")
|
||||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const dateMethods = new Set([
|
|||||||
"getTimezoneOffset",
|
"getTimezoneOffset",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||||
|
|
||||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "now":
|
case "now":
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { type AstNode, InterpreterRuntimeError, supportedSyntaxMessage } from ".
|
|||||||
import { typeofValue } from "../interpreter/references.js"
|
import { typeofValue } from "../interpreter/references.js"
|
||||||
import { copyIn, copyOut } from "../tool-runtime.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 => {
|
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "stringify": {
|
case "stringify": {
|
||||||
@@ -16,7 +18,7 @@ export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNo
|
|||||||
}
|
}
|
||||||
const space = args[2]
|
const space = args[2]
|
||||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value"), "json"), null, indent)
|
||||||
}
|
}
|
||||||
case "parse": {
|
case "parse": {
|
||||||
const text = args[0]
|
const text = args[0]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { boundedData, coerceToString } from "./value.js"
|
|||||||
|
|
||||||
export const objectMethodsPreservingIdentity = new Set(["assign", "values", "entries", "fromEntries"])
|
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 => {
|
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||||
const requireObject = (): Record<string, unknown> => {
|
const requireObject = (): Record<string, unknown> => {
|
||||||
const input = args[0]
|
const input = args[0]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const escapeRegexHint =
|
|||||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
'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 => {
|
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 (arg instanceof CodeModeRegExp) return arg.regex
|
||||||
if (typeof arg === "string") {
|
if (typeof arg === "string") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,10 +61,19 @@ export const coerceToString = (value: unknown): string => {
|
|||||||
export const coerceToNumber = (value: unknown): number => {
|
export const coerceToNumber = (value: unknown): number => {
|
||||||
if (value instanceof CodeModeDate) return value.time
|
if (value instanceof CodeModeDate) return value.time
|
||||||
if (isCodeModeValue(value)) return Number.NaN
|
if (isCodeModeValue(value)) return Number.NaN
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
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]
|
const raw = args[0]
|
||||||
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
|
||||||
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
|
||||||
@@ -72,12 +81,16 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
|
|||||||
if (ref.name === "Boolean") return true
|
if (ref.name === "Boolean") return true
|
||||||
if (ref.name === "Number") return coerceToNumber(raw)
|
if (ref.name === "Number") return coerceToNumber(raw)
|
||||||
if (ref.name === "String") return coerceToString(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))
|
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||||
return parseFloat(coerceToString(raw))
|
return parseFloat(coerceToString(raw))
|
||||||
}
|
}
|
||||||
const value = boundedData(raw, `${ref.name} input`)
|
const value = boundedData(raw, `${ref.name} input`)
|
||||||
if (ref.name === "Number") return coerceToNumber(value)
|
if (ref.name === "Number") return coerceToNumber(value)
|
||||||
if (ref.name === "Boolean") return Boolean(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") {
|
if (ref.name === "parseInt") {
|
||||||
const radix = args[1]
|
const radix = args[1]
|
||||||
if (radix !== undefined && typeof radix !== "number") {
|
if (radix !== undefined && typeof radix !== "number") {
|
||||||
|
|||||||
@@ -118,8 +118,7 @@ export class ToolRuntimeError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> =>
|
const isDefinition = <R>(value: Definition<R> | Tools<R>): value is Definition<R> => isToolDefinition<R>(value)
|
||||||
isToolDefinition<R>(value)
|
|
||||||
|
|
||||||
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
const runHost = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ToolError, R> =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
@@ -257,18 +256,31 @@ const copyBounded = (
|
|||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
export const copyOut = (value: unknown, undefinedAsNull = false): unknown => {
|
// "json" mirrors JSON.stringify (undefined object values drop, undefined array elements become
|
||||||
if (value === undefined && undefinedAsNull) return null
|
// 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
|
||||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
|
// Array.from densifies holes so sparse arrays normalize at the boundary like JSON does.
|
||||||
return Array.from(value, (item) => copyOut(item, undefinedAsNull))
|
return Array.from(value, (item) => {
|
||||||
|
const copied = copyOut(item, mode)
|
||||||
|
return copied === undefined && mode === "json" ? null : copied
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) {
|
||||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)]))
|
return Object.fromEntries(
|
||||||
|
Object.entries(value)
|
||||||
|
.map(([key, item]) => [key, copyOut(item, mode)] as const)
|
||||||
|
.filter(([, item]) => !(item === undefined && mode === "json")),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
@@ -696,13 +708,13 @@ export const make = <R>(
|
|||||||
invokeDefinition(
|
invokeDefinition(
|
||||||
"search",
|
"search",
|
||||||
searchTool,
|
searchTool,
|
||||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
|
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
invoke: (path, args) =>
|
invoke: (path, args) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const name = canonicalSegments(path).join(".")
|
const name = canonicalSegments(path).join(".")
|
||||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json"))
|
||||||
const tool = resolve(root, path)
|
const tool = resolve(root, path)
|
||||||
return yield* invokeDefinition(name, tool, externalArgs)
|
return yield* invokeDefinition(name, tool, externalArgs)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -453,6 +453,56 @@ describe("CodeMode schema flexibility", () => {
|
|||||||
expect(observed).toStrictEqual([{ id: 42 }])
|
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 () => {
|
test("renders JSON Schema outputs and $defs references", async () => {
|
||||||
const lookup = Tool.make({
|
const lookup = Tool.make({
|
||||||
description: "Look up a user",
|
description: "Look up a user",
|
||||||
|
|||||||
@@ -258,11 +258,23 @@ 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)", () => {
|
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.
|
// Tool-call arguments funnel through copyOut too, so this one function pins both boundaries.
|
||||||
expect(ToolRuntime.copyOut(NaN)).toBeNull()
|
expect(ToolRuntime.copyOut(NaN, "json")).toBeNull()
|
||||||
expect(ToolRuntime.copyOut(Infinity)).toBeNull()
|
expect(ToolRuntime.copyOut(Infinity, "json")).toBeNull()
|
||||||
expect(ToolRuntime.copyOut(-Infinity)).toBeNull()
|
expect(ToolRuntime.copyOut(-Infinity, "nullify")).toBeNull()
|
||||||
expect(ToolRuntime.copyOut(42)).toBe(42)
|
expect(ToolRuntime.copyOut(42, "json")).toBe(42)
|
||||||
expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] })
|
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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -669,3 +681,177 @@ describe("destructuring assignment", () => {
|
|||||||
expect(err.message).toContain("Property key must be a string or number")
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ type MakeInput<
|
|||||||
T extends Tag | undefined = undefined,
|
T extends Tag | undefined = undefined,
|
||||||
> = NodeIdentity & {
|
> = NodeIdentity & {
|
||||||
readonly layer: Implementation
|
readonly layer: Implementation
|
||||||
readonly deps: (Items | (() => Items)) & CheckDependencies<Implementation, NoInfer<Items>>
|
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||||
readonly tag?: T
|
readonly tag?: T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +90,7 @@ export function make<
|
|||||||
name: input.service !== undefined ? input.service.key : input.name,
|
name: input.service !== undefined ? input.service.key : input.name,
|
||||||
service: input.service,
|
service: input.service,
|
||||||
implementation: input.layer,
|
implementation: input.layer,
|
||||||
get dependencies() {
|
dependencies: input.deps,
|
||||||
return typeof input.deps === "function" ? input.deps() : input.deps
|
|
||||||
},
|
|
||||||
tag: input.tag,
|
tag: input.tag,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+150
-63
@@ -13,8 +13,10 @@ import { EventV2 } from "../event"
|
|||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { IntegrationConnection } from "../integration/connection"
|
import { IntegrationConnection } from "../integration/connection"
|
||||||
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { waitForAbort } from "../process"
|
import { waitForAbort } from "../process"
|
||||||
|
import { State } from "../state"
|
||||||
import { MCPClient } from "./client"
|
import { MCPClient } from "./client"
|
||||||
import { MCPOAuth } from "./oauth"
|
import { MCPOAuth } from "./oauth"
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ type ServerEntry = {
|
|||||||
prompts?: ReadonlyArray<Prompt>
|
prompts?: ReadonlyArray<Prompt>
|
||||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||||
integrationID?: Integration.ID
|
integrationID?: Integration.ID
|
||||||
|
registration?: State.Registration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
||||||
@@ -127,6 +130,10 @@ const URL_ELICITATION_FIELD_KEY = "elicitation"
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
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 tools: () => Effect.Effect<Tool[]>
|
||||||
readonly callTool: (input: {
|
readonly callTool: (input: {
|
||||||
readonly server: ServerName | string
|
readonly server: ServerName | string
|
||||||
@@ -170,6 +177,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||||
const runtime = new Map<ServerName, ServerEntry>()
|
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>()
|
const urlElicitations = new Map<string, Form.ID>()
|
||||||
for (const entry of documents) {
|
for (const entry of documents) {
|
||||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||||
@@ -183,14 +193,9 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
// 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.
|
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||||
const registrations: Array<{
|
const owned = new Set<Integration.ID>()
|
||||||
readonly name: ServerName
|
const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||||
readonly remote: typeof ConfigMCP.Remote.Type
|
if (entry.config.type !== "remote" || entry.config.oauth === false) return
|
||||||
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
|
const remote = entry.config
|
||||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
// 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.
|
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||||
@@ -200,27 +205,24 @@ export const layer = Layer.effect(
|
|||||||
.update(name + "\u0000" + remote.url)
|
.update(name + "\u0000" + remote.url)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
entry.integrationID = Integration.ID.make(suffix)
|
const integrationID = Integration.ID.make(suffix)
|
||||||
registrations.push({
|
entry.integrationID = integrationID
|
||||||
name,
|
owned.add(integrationID)
|
||||||
remote,
|
const methodID = Integration.MethodID.make(suffix)
|
||||||
integrationID: entry.integrationID,
|
entry.registration = yield* integration
|
||||||
methodID: Integration.MethodID.make(suffix),
|
.transform((draft) => {
|
||||||
})
|
draft.update(integrationID, (ref) => {
|
||||||
}
|
ref.name = name
|
||||||
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({
|
draft.method.update({
|
||||||
integrationID: reg.integrationID,
|
integrationID,
|
||||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
method: { id: methodID, type: "oauth", label: name },
|
||||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
authorize: () => MCPOAuth.authorize({ name, config: remote, 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 requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||||
const name = ServerName.make(server)
|
const name = ServerName.make(server)
|
||||||
@@ -420,36 +422,44 @@ 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>) =>
|
||||||
|
fork(
|
||||||
|
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||||
|
locks.withLock(name),
|
||||||
|
Effect.ignore,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||||
connection.onClose(() => {
|
const live = whenLive(name, entry, connection)
|
||||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
connection.onClose(() =>
|
||||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
live(
|
||||||
if (entry.client !== connection) return
|
Effect.gen(function* () {
|
||||||
entry.client = undefined
|
entry.client = undefined
|
||||||
entry.tools = undefined
|
entry.tools = undefined
|
||||||
entry.prompts = undefined
|
entry.prompts = undefined
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
entry.status = { status: "failed", error: "Connection closed" }
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).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(() => {
|
|
||||||
fork(
|
|
||||||
refreshTools(name, entry, connection).pipe(
|
|
||||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||||
connection.onPromptsChanged(() => {
|
connection.onToolsChanged(() =>
|
||||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
live(
|
||||||
})
|
refreshTools(name, entry, connection).pipe(
|
||||||
connection.onResourcesChanged(() => {
|
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||||
if (entry.client !== connection) return
|
),
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
),
|
||||||
})
|
)
|
||||||
|
connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
|
||||||
|
connection.onResourcesChanged(() => live(events.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||||
@@ -472,6 +482,10 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const startServer = (name: ServerName, entry: ServerEntry) =>
|
const startServer = (name: ServerName, entry: ServerEntry) =>
|
||||||
Effect.gen(function* () {
|
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)
|
const scope = yield* Scope.fork(root)
|
||||||
entry.scope = scope
|
entry.scope = scope
|
||||||
const authProvider = yield* connectProvider(entry)
|
const authProvider = yield* connectProvider(entry)
|
||||||
@@ -495,7 +509,7 @@ export const layer = Layer.effect(
|
|||||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* events.publish(McpEvent.ResourcesChanged, { 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)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
@@ -509,6 +523,19 @@ export const layer = Layer.effect(
|
|||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
}).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.
|
// Disabled servers settle their startup immediately so queries never block on them.
|
||||||
for (const [name, entry] of runtime) {
|
for (const [name, entry] of runtime) {
|
||||||
if (entry.config.disabled) {
|
if (entry.config.disabled) {
|
||||||
@@ -516,27 +543,24 @@ export const layer = Layer.effect(
|
|||||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fork(startServer(name, entry))
|
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
// 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.
|
// 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) =>
|
const reconnect = (integrationID: Integration.ID) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||||
if (!match) return
|
if (!match) return
|
||||||
const [name, entry] = match
|
const name = match[0]
|
||||||
if (entry.config.disabled) return
|
yield* Effect.gen(function* () {
|
||||||
if (entry.scope) {
|
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
|
||||||
yield* Scope.close(entry.scope, Exit.void)
|
const entry = runtime.get(name)
|
||||||
entry.scope = undefined
|
if (!entry || entry.integrationID !== integrationID) return
|
||||||
entry.client = undefined
|
if (entry.status.status === "disabled") return
|
||||||
entry.tools = undefined
|
yield* stopServer(name, entry)
|
||||||
entry.prompts = undefined
|
|
||||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
|
||||||
}
|
|
||||||
yield* startServer(name, entry)
|
yield* startServer(name, entry)
|
||||||
|
}).pipe(locks.withLock(name))
|
||||||
})
|
})
|
||||||
fork(
|
fork(
|
||||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
@@ -546,10 +570,13 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
// 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",
|
concurrency: "unbounded",
|
||||||
discard: true,
|
discard: true,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
return Service.of({
|
return Service.of({
|
||||||
servers: Effect.fn("MCP.servers")(function* () {
|
servers: Effect.fn("MCP.servers")(function* () {
|
||||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||||
@@ -562,6 +589,66 @@ 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* () {
|
tools: Effect.fn("MCP.tools")(function* () {
|
||||||
yield* whenAllReady
|
yield* whenAllReady
|
||||||
return Array.from(runtime.values())
|
return Array.from(runtime.values())
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Permission } from "@opencode-ai/schema/permission"
|
|||||||
import { EventV2 } from "./event"
|
import { EventV2 } from "./event"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AgentV2 } from "./agent"
|
import { AgentV2 } from "./agent"
|
||||||
import { SessionV2 } from "./session"
|
import { SessionErrors } from "./session/error"
|
||||||
|
import { SessionSchema } from "./session/schema"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { Wildcard } from "./util/wildcard"
|
import { Wildcard } from "./util/wildcard"
|
||||||
import { PermissionSaved } from "./permission/saved"
|
import { PermissionSaved } from "./permission/saved"
|
||||||
@@ -98,11 +99,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionV2.NotFoundError>
|
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
|
||||||
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionV2.NotFoundError>
|
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||||
readonly get: (id: ID) => Effect.Effect<Request | undefined>
|
readonly get: (id: ID) => Effect.Effect<Request | undefined>
|
||||||
readonly forSession: (sessionID: SessionV2.ID) => Effect.Effect<ReadonlyArray<Request>>
|
readonly forSession: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Request>>
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +143,12 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const configured = Effect.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID, agentID?: AgentV2.ID) {
|
const configured = Effect.fn("PermissionV2.configured")(function* (
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
agentID?: AgentV2.ID,
|
||||||
|
) {
|
||||||
const session = yield* sessions.get(sessionID)
|
const session = yield* sessions.get(sessionID)
|
||||||
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
|
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||||
return agent?.permissions ?? missingAgentPermissions
|
return agent?.permissions ?? missingAgentPermissions
|
||||||
})
|
})
|
||||||
@@ -301,7 +305,7 @@ const layer = Layer.effect(
|
|||||||
return pending.get(id)?.request
|
return pending.get(id)?.request
|
||||||
})
|
})
|
||||||
|
|
||||||
const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
|
const forSession = Effect.fn("PermissionV2.forSession")(function* (sessionID: SessionSchema.ID) {
|
||||||
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import { fromRow } from "./session/info"
|
|||||||
import { SessionRunner } from "./session/runner/index"
|
import { SessionRunner } from "./session/runner/index"
|
||||||
import { SessionStore } from "./session/store"
|
import { SessionStore } from "./session/store"
|
||||||
import { SessionExecution } from "./session/execution"
|
import { SessionExecution } from "./session/execution"
|
||||||
|
import { MessageDecodeError, NotFoundError } from "./session/error"
|
||||||
import { makeGlobalNode } from "./effect/app-node"
|
import { makeGlobalNode } from "./effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map"
|
import { LocationServiceMap } from "./location-service-map"
|
||||||
import { MessageDecodeError } from "./session/error"
|
|
||||||
import { SessionEvent } from "./session/event"
|
import { SessionEvent } from "./session/event"
|
||||||
import { SessionPending } from "./session/pending"
|
import { SessionPending } from "./session/pending"
|
||||||
import { SessionGenerate } from "./session/generate"
|
import { SessionGenerate } from "./session/generate"
|
||||||
@@ -108,10 +108,6 @@ type ForkInput = {
|
|||||||
messageID?: SessionMessage.ID
|
messageID?: SessionMessage.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||||
"Session.OperationUnavailableError",
|
"Session.OperationUnavailableError",
|
||||||
{
|
{
|
||||||
@@ -119,7 +115,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export { MessageDecodeError } from "./session/error"
|
export { MessageDecodeError, NotFoundError }
|
||||||
|
|
||||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -1033,8 +1029,7 @@ const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer: layer.pipe(Layer.orDie),
|
layer: layer.pipe(Layer.orDie),
|
||||||
// Defer the execution node across the Session/runner module cycle until the graph is compiled.
|
deps: [
|
||||||
deps: () => [
|
|
||||||
Job.node,
|
Job.node,
|
||||||
Database.node,
|
Database.node,
|
||||||
EventV2.node,
|
EventV2.node,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
export * as SessionErrors from "./error"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
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", {
|
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
|
|||||||
@@ -410,7 +410,9 @@ const layer = Layer.effect(
|
|||||||
yield* events.publish(SessionEvent.Compaction.Failed, {
|
yield* events.publish(SessionEvent.Compaction.Failed, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||||
|
? { type: "aborted", message: "Compaction cancelled" }
|
||||||
|
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||||
inputID: unsettled.id,
|
inputID: unsettled.id,
|
||||||
})
|
})
|
||||||
return yield* Effect.failCause(compacted.cause)
|
return yield* Effect.failCause(compacted.cause)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const b = make({ service: B, layer: bLayer, deps: [a] })
|
|||||||
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
const c = make({ service: C, layer: cLayer, deps: [a, b] })
|
||||||
const failing = make({ service: A, layer: failingA, deps: [] })
|
const failing = make({ service: A, layer: failingA, deps: [] })
|
||||||
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
|
||||||
make({ service: B, layer: bLayer, deps: () => [a] })
|
|
||||||
const inputA = LayerNode.unbound(A, tags.values.app)
|
const inputA = LayerNode.unbound(A, tags.values.app)
|
||||||
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
|
||||||
|
|
||||||
@@ -47,9 +46,6 @@ make({ service: A, name: "a", layer: aLayer, deps: [] })
|
|||||||
// @ts-expect-error B requires A
|
// @ts-expect-error B requires A
|
||||||
make({ service: B, layer: bLayer, deps: [] })
|
make({ service: B, layer: bLayer, deps: [] })
|
||||||
|
|
||||||
// @ts-expect-error Lazy dependencies must still provide A
|
|
||||||
make({ service: B, layer: bLayer, deps: () => [] })
|
|
||||||
|
|
||||||
// @ts-expect-error C requires A and B
|
// @ts-expect-error C requires A and B
|
||||||
make({ service: C, layer: cLayer, deps: [a] })
|
make({ service: C, layer: cLayer, deps: [a] })
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,6 @@ describe("layer node", () => {
|
|||||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
expect(await Effect.runPromise(program)).toBe("hello production")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves lazy dependencies when compiling", async () => {
|
|
||||||
const greeting = make({ service: Greeting, layer: greetingLayer, deps: () => [value] })
|
|
||||||
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(greeting)))
|
|
||||||
expect(await Effect.runPromise(program)).toBe("hello production")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("exposes roots but hides transitive dependencies", () => {
|
test("exposes roots but hides transitive dependencies", () => {
|
||||||
const layer = build(LayerNode.group([greeting]))
|
const layer = build(LayerNode.group([greeting]))
|
||||||
const check: Layer.Layer<Greeting> = layer
|
const check: Layer.Layer<Greeting> = layer
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const emptyMcpLayer = Layer.succeed(
|
|||||||
MCP.Service,
|
MCP.Service,
|
||||||
MCP.Service.of({
|
MCP.Service.of({
|
||||||
servers: () => Effect.succeed([]),
|
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([]),
|
tools: () => Effect.succeed([]),
|
||||||
callTool: () => Effect.die("unused mcp.callTool"),
|
callTool: () => Effect.die("unused mcp.callTool"),
|
||||||
instructions: () => Effect.succeed([]),
|
instructions: () => Effect.succeed([]),
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ function resourceServer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
function resourceMcpLayer(
|
||||||
|
server: string | typeof ConfigMCP.Server.Type,
|
||||||
|
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||||
|
) {
|
||||||
const directory = AbsolutePath.make(import.meta.dir)
|
const directory = AbsolutePath.make(import.meta.dir)
|
||||||
const unusedIntegration = () => Effect.die("unused integration service")
|
const unusedIntegration = () => Effect.die("unused integration service")
|
||||||
return MCP.layer.pipe(
|
return MCP.layer.pipe(
|
||||||
@@ -164,7 +167,12 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
|
|||||||
type: "document",
|
type: "document",
|
||||||
info: new Config.Info({
|
info: new Config.Info({
|
||||||
mcp: new ConfigMCP.Info({
|
mcp: new ConfigMCP.Info({
|
||||||
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
|
servers: {
|
||||||
|
resources:
|
||||||
|
typeof server === "string"
|
||||||
|
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
|
||||||
|
: server,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -634,6 +642,120 @@ 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", () =>
|
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|||||||
@@ -1892,6 +1892,35 @@ 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", () =>
|
it.effect("settles an admitted manual compaction when pre-start resolution throws", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "fs/promises"
|
|||||||
import { realpathSync } from "node:fs"
|
import { realpathSync } from "node:fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { DateTime, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
|
|||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||||
const progressOverflowCommand = (bytes: number) =>
|
const progressOverflowCommand = (bytes: number, release: string) =>
|
||||||
isWindows
|
isWindows
|
||||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
|
? `[Console]::Out.Write(('x' * ${bytes})); while (!(Test-Path -LiteralPath '${release}')) { Start-Sleep -Milliseconds 50 }`
|
||||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
|
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; while [ ! -e '${release}' ]; do sleep 0.05; done`
|
||||||
|
|
||||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -417,33 +417,49 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("reports bounded output progress for a running command", () =>
|
it.live(
|
||||||
|
"reports bounded output progress for a running command",
|
||||||
|
() =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
const release = "shell-progress-release"
|
||||||
|
const releasePath = path.join(tmp.path, release)
|
||||||
return withSession(tmp.path, (registry) =>
|
return withSession(tmp.path, (registry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const progress: ToolRegistry.Progress[] = []
|
const observed = yield* Deferred.make<ToolRegistry.Progress>()
|
||||||
yield* settleTool(registry, {
|
yield* settleTool(registry, {
|
||||||
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
|
...call(
|
||||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
{ 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, ""))
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(progress).toHaveLength(1)
|
const progress = yield* Deferred.await(observed)
|
||||||
expect(progress[0]?.structured).toEqual({ truncated: true })
|
expect(progress.structured).toEqual({ truncated: true })
|
||||||
const content = progress[0]?.content[0]
|
const content = progress.content[0]
|
||||||
expect(content?.type).toBe("text")
|
expect(content?.type).toBe("text")
|
||||||
if (content?.type !== "text") return
|
if (content?.type !== "text") return
|
||||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
||||||
ShellTool.MAX_CAPTURE_BYTES,
|
ShellTool.MAX_CAPTURE_BYTES,
|
||||||
)
|
)
|
||||||
}),
|
}).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
),
|
),
|
||||||
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("returns a useful timeout settlement", () =>
|
it.live("returns a useful timeout settlement", () =>
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ You can also install it with the following package managers.
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="bun">
|
<Tab title="bun">
|
||||||
```bash
|
```bash
|
||||||
bun install -g @opencode-ai/cli@next
|
bun install -g --trust @opencode-ai/cli@next
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="pnpm">
|
<Tab title="pnpm">
|
||||||
```bash
|
```bash
|
||||||
pnpm install -g @opencode-ai/cli@next
|
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="Yarn">
|
<Tab title="Yarn">
|
||||||
@@ -37,6 +37,9 @@ You can also install it with the following package managers.
|
|||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</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>
|
<Note>During beta, the binary is called `opencode2`.</Note>
|
||||||
|
|
||||||
### Homebrew
|
### Homebrew
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Tab {
|
|||||||
const ComposerContext = createContext<{
|
const ComposerContext = createContext<{
|
||||||
register: (tab: Tab) => () => void
|
register: (tab: Tab) => () => void
|
||||||
active: (id: string) => boolean
|
active: (id: string) => boolean
|
||||||
|
close: () => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
export function useComposerTab() {
|
export function useComposerTab() {
|
||||||
@@ -73,6 +74,7 @@ export function Composer(props: ComposerProps) {
|
|||||||
active(id: string) {
|
active(id: string) {
|
||||||
return props.open && store.active === id
|
return props.open && store.active === id
|
||||||
},
|
},
|
||||||
|
close,
|
||||||
}
|
}
|
||||||
|
|
||||||
const keymap = Keymap.use()
|
const keymap = Keymap.use()
|
||||||
|
|||||||
@@ -59,9 +59,11 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
group: "Composer",
|
group: "Composer",
|
||||||
bind: "up",
|
bind: "up",
|
||||||
run() {
|
run() {
|
||||||
const list = entries()
|
if (store.selected === 0) {
|
||||||
if (list.length === 0) return
|
composer.close()
|
||||||
setStore("selected", (prev) => (prev - 1 + list.length) % list.length)
|
return
|
||||||
|
}
|
||||||
|
setStore("selected", (prev) => prev - 1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -160,9 +160,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
group: "Composer",
|
group: "Composer",
|
||||||
bind: "up",
|
bind: "up",
|
||||||
run() {
|
run() {
|
||||||
const list = entries()
|
if (store.selected === 0) {
|
||||||
if (list.length === 0) return
|
composer.close()
|
||||||
moveTo((store.selected - 1 + list.length) % list.length, true)
|
return
|
||||||
|
}
|
||||||
|
moveTo(store.selected - 1, true)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1440,9 +1440,10 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
|||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax } = useTheme()
|
||||||
const status = () => props.message.status
|
const status = () => props.message.status
|
||||||
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
|
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 content = createMemo(() => text().trim())
|
const content = createMemo(() => text().trim())
|
||||||
const color = () => (status() === "failed" ? themeV2.text.feedback.error() : themeV2.text.subdued())
|
const color = () => (status() === "failed" && !cancelled() ? themeV2.text.feedback.error() : themeV2.text.subdued())
|
||||||
return (
|
return (
|
||||||
<box>
|
<box>
|
||||||
<box flexDirection="row" alignItems="center">
|
<box flexDirection="row" alignItems="center">
|
||||||
@@ -1454,11 +1455,14 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
|||||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={status() === "failed"}>
|
<Match when={status() === "failed" && !cancelled()}>
|
||||||
<text fg={color()}>✗</text>
|
<text fg={color()}>✗</text>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
<text fg={color()}>Compaction</text>
|
<text fg={color()}>Compaction</text>
|
||||||
|
<Show when={cancelled()}>
|
||||||
|
<text fg={color()}>· cancelled</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
<box border={["top"]} borderColor={color()} flexGrow={1} />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
Reference in New Issue
Block a user