mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f36bb750 | |||
| c50554d907 | |||
| 3f5ad8441f | |||
| cf6e5b3604 | |||
| cbcf191fdb | |||
| ba0bbdafaa | |||
| a288cb5a0c | |||
| 57ff57595a | |||
| 584fdefe6f | |||
| c310ef82f4 | |||
| 4e85a37590 | |||
| fe9b051d1a | |||
| 33f1b269e9 | |||
| 0d68b0bb20 | |||
| 4e56998d3c | |||
| d625bc86fc | |||
| 5f437a09b0 | |||
| 7d4496eafc | |||
| 1f2de535aa | |||
| 529d55b1c3 | |||
| deb5b144c3 | |||
| cd3cca0006 | |||
| 08a7080e11 | |||
| f2579c41b6 | |||
| c4830c147d | |||
| 22c23cb44c | |||
| 60ce33cde8 | |||
| 6ea8247e0f | |||
| 987242b3e8 | |||
| fafb04ed59 | |||
| 87d5b27668 | |||
| c7a7900ff2 | |||
| f2a4011371 | |||
| 4bc8faa01c |
@@ -132,18 +132,14 @@
|
|||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
"ws": "8.21.0",
|
"ws": "8.21.0",
|
||||||
},
|
},
|
||||||
@@ -900,6 +896,9 @@
|
|||||||
"version": "1.17.13",
|
"version": "1.17.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/commit-mono": "5.2.5",
|
"@fontsource/commit-mono": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-math": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||||
"@napi-rs/canvas": "1.0.2",
|
"@napi-rs/canvas": "1.0.2",
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -1026,6 +1025,7 @@
|
|||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
@@ -1795,6 +1795,12 @@
|
|||||||
|
|
||||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||||
|
|
||||||
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
||||||
|
|
||||||
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -703,7 +703,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
|||||||
providerExecuted: block.type === "server_tool_use",
|
providerExecuted: block.type === "server_tool_use",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
[
|
||||||
|
...events,
|
||||||
|
LLMEvent.toolInputStart({
|
||||||
|
id: block.id ?? String(event.index),
|
||||||
|
name: block.name ?? "",
|
||||||
|
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -561,7 +561,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
hasToolCalls:
|
||||||
|
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasToolCalls,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
tools: result.tools,
|
tools: result.tools,
|
||||||
reasoningSignatures: Object.fromEntries(
|
reasoningSignatures: Object.fromEntries(
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -437,7 +464,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||||
// JSON parse failures fail the stream at the boundary rather than at halt.
|
// valid calls and malformed local calls settle independently.
|
||||||
const finished =
|
const finished =
|
||||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -835,7 +835,9 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
|||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
|
hasFunctionCall:
|
||||||
|
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasFunctionCall,
|
||||||
tools: result.tools,
|
tools: result.tools,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
@@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
|
|||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
|||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
const raw = inputOverride ?? tool.input
|
||||||
Effect.map(
|
return parseToolInput(route, tool.name, raw).pipe(
|
||||||
(input): ToolCall =>
|
Effect.map((input): ToolCall | ToolInputError =>
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input,
|
input,
|
||||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
tool.providerExecuted
|
||||||
|
? Effect.fail(error)
|
||||||
|
: Effect.succeed(
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
raw,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||||
|
event.type === "tool-input-error"
|
||||||
|
? [event]
|
||||||
|
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||||
|
|
||||||
/** Store the updated tool and produce the optional public delta event. */
|
/** Store the updated tool and produce the optional public delta event. */
|
||||||
const appendTool = <K extends StreamKey>(
|
const appendTool = <K extends StreamKey>(
|
||||||
@@ -158,8 +176,9 @@ export const appendExisting = <K extends StreamKey>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||||
* from state, and return the optional public `tool-call` event. Missing keys are
|
* from state, and return either a call or a non-executable local input error.
|
||||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
* Missing keys are a no-op because some providers emit stop events for
|
||||||
|
* non-tool content blocks.
|
||||||
*/
|
*/
|
||||||
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -167,10 +186,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
|||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
|||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool, input)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool, input),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
||||||
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
* not emit per-tool stop events, so all accumulated calls finish independently
|
||||||
* receives a terminal `finish_reason`.
|
* when the choice receives a terminal `finish_reason`.
|
||||||
*/
|
*/
|
||||||
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
|||||||
return {
|
return {
|
||||||
tools: empty<K>(),
|
tools: empty<K>(),
|
||||||
events: yield* Effect.forEach(pending, (tool) =>
|
events: yield* Effect.forEach(pending, (tool) =>
|
||||||
toolCall(route, tool).pipe(
|
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
|
||||||
Effect.map((call) => [
|
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
call,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
).pipe(Effect.map((events) => events.flat())),
|
).pipe(Effect.map((events) => events.flat())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
|
|||||||
type: Schema.tag("tool-input-start"),
|
type: Schema.tag("tool-input-start"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||||
@@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
|
|||||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||||
|
|
||||||
|
/** A local tool call whose final input could not be decoded. */
|
||||||
|
export const ToolInputError = Schema.Struct({
|
||||||
|
type: Schema.tag("tool-input-error"),
|
||||||
|
id: ToolCallID,
|
||||||
|
name: Schema.String,
|
||||||
|
raw: Schema.String,
|
||||||
|
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||||
|
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||||
|
|
||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.tag("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
@@ -216,6 +226,7 @@ const llmEventTagged = Schema.Union([
|
|||||||
ToolInputStart,
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
ToolInputEnd,
|
ToolInputEnd,
|
||||||
|
ToolInputError,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
@@ -253,6 +264,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
|
||||||
|
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||||
ToolResult.make({
|
ToolResult.make({
|
||||||
@@ -283,6 +296,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
|||||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
|
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
@@ -548,6 +562,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
|||||||
return reduceToolInputDelta(next, event)
|
return reduceToolInputDelta(next, event)
|
||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
return reduceToolInputEnd(next, event)
|
return reduceToolInputEnd(next, event)
|
||||||
|
case "tool-input-error": {
|
||||||
|
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
|
||||||
|
return { ...next, toolInputs }
|
||||||
|
}
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
return reduceToolCall(next, event)
|
return reduceToolCall(next, event)
|
||||||
case "tool-result":
|
case "tool-result":
|
||||||
|
|||||||
+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
@@ -484,6 +484,30 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps malformed server tool input terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(LLMError)
|
||||||
|
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails with a typed provider error for stream error frames", () =>
|
it.effect("fails with a typed provider error for stream error frames", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
|||||||
@@ -303,6 +303,32 @@ describe("Bedrock Converse route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("emits malformed tool input as an unexecuted tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = eventStreamBody(
|
||||||
|
["messageStart", { role: "assistant" }],
|
||||||
|
[
|
||||||
|
"contentBlockStart",
|
||||||
|
{
|
||||||
|
contentBlockIndex: 0,
|
||||||
|
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
|
||||||
|
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||||
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||||
|
|
||||||
|
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
|
||||||
|
id: "tool_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes reasoning deltas", () =>
|
it.effect("decodes reasoning deltas", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
|
|||||||
@@ -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) {
|
||||||
{ choices: [{ delta: { content: "Hello" } }] },
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
Effect.provide(
|
||||||
)
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { [field]: "thinking" } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.text).toBe("Hello")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: field },
|
||||||
|
})
|
||||||
|
|
||||||
expect(response.reasoning).toBe("thinking")
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
expect(response.text).toBe("Hello")
|
LLM.request({ model, messages: [response.message] }),
|
||||||
expect(response.events).toMatchObject([
|
)
|
||||||
{ type: "step-start", index: 0 },
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||||
{ type: "reasoning-start", id: "reasoning-0" },
|
}
|
||||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
|
||||||
{ type: "reasoning-end", id: "reasoning-0" },
|
|
||||||
{ type: "text-start", id: "text-0" },
|
|
||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
|
||||||
{ type: "text-end", id: "text-0" },
|
|
||||||
{ type: "step-finish", index: 0, reason: "stop" },
|
|
||||||
{ type: "finish", reason: "stop" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
@@ -1259,6 +1259,69 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||||
|
},
|
||||||
|
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"partial',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("settles malformed function arguments when output_item.added is absent", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"partial',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
|
|||||||
@@ -95,4 +95,19 @@ describe("LLMResponse reducer", () => {
|
|||||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("clears malformed tool input without appending an executable call", () => {
|
||||||
|
const state = reduce([
|
||||||
|
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||||
|
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(state.toolInputs).toEqual({})
|
||||||
|
expect(state.message.content).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -64,6 +64,73 @@ describe("ToolStream", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("finalizes malformed local input as a non-executable tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
})
|
||||||
|
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||||
|
|
||||||
|
expect(finished).toEqual({
|
||||||
|
tools: {},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves valid siblings when one parallel input is malformed", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
id: "call_valid",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"weather"}',
|
||||||
|
})
|
||||||
|
const tools = ToolStream.start(valid, 1, {
|
||||||
|
id: "call_invalid",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
})
|
||||||
|
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
|
||||||
|
|
||||||
|
expect(finished).toEqual({
|
||||||
|
tools: {},
|
||||||
|
events: [
|
||||||
|
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||||
|
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||||
|
{
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_invalid",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps malformed provider-executed input terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||||
|
id: "call_1",
|
||||||
|
name: "web_search",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
providerExecuted: true,
|
||||||
|
})
|
||||||
|
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
|
||||||
|
|
||||||
|
expect(result._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves providerExecuted and clears all tools", () =>
|
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
|||||||
@@ -12,16 +12,7 @@
|
|||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./daemon": "./src/daemon.ts",
|
"./daemon": "./src/daemon.ts",
|
||||||
"./mini": "./src/mini/index.ts",
|
"./run": "./src/run/index.ts",
|
||||||
"./mini/footer.command": "./src/mini/footer.command.tsx",
|
|
||||||
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
|
|
||||||
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
|
|
||||||
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
|
|
||||||
"./mini/footer.question": "./src/mini/footer.question.tsx",
|
|
||||||
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
|
|
||||||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
|
||||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
|
||||||
"./mini/*": "./src/mini/*.ts",
|
|
||||||
"./server-process": "./src/server-process.ts"
|
"./server-process": "./src/server-process.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,18 +31,14 @@
|
|||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import childProcess from "node:child_process"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
|
||||||
|
const command = Object.keys(packageJson.bin ?? {})[0]
|
||||||
|
if (!command) throw new Error("OpenCode package does not declare a binary")
|
||||||
|
|
||||||
|
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
||||||
|
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||||
|
const sourceBinary = platform === "windows" ? `${command}.exe` : command
|
||||||
|
const targetBinary = path.resolve(directory, packageJson.bin[command])
|
||||||
|
const dependencies = packageJson.optionalDependencies ?? {}
|
||||||
|
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
|
||||||
|
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
|
||||||
|
|
||||||
|
function supportsAvx2() {
|
||||||
|
if (arch !== "x64") return false
|
||||||
|
if (platform === "linux") {
|
||||||
|
try {
|
||||||
|
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === "darwin") {
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1500,
|
||||||
|
})
|
||||||
|
return result.status === 0 && (result.stdout || "").trim() === "1"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === "windows") {
|
||||||
|
const script =
|
||||||
|
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||||
|
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 3000,
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
if (result.status !== 0) continue
|
||||||
|
const output = (result.stdout || "").trim().toLowerCase()
|
||||||
|
if (output === "true" || output === "1") return true
|
||||||
|
if (output === "false" || output === "0") return false
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMusl() {
|
||||||
|
if (platform !== "linux") return false
|
||||||
|
try {
|
||||||
|
if (fs.existsSync("/etc/alpine-release")) return true
|
||||||
|
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||||
|
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageNames() {
|
||||||
|
const baseline = arch === "x64" && !supportsAvx2()
|
||||||
|
const names =
|
||||||
|
platform === "linux"
|
||||||
|
? isMusl()
|
||||||
|
? arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||||
|
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||||
|
: [`${base}-musl`, base]
|
||||||
|
: arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||||
|
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||||
|
: [base, `${base}-musl`]
|
||||||
|
: arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline`, base]
|
||||||
|
: [base, `${base}-baseline`]
|
||||||
|
: [base]
|
||||||
|
return names.filter((name) => dependencies[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBinary(source) {
|
||||||
|
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||||
|
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||||
|
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||||
|
try {
|
||||||
|
fs.linkSync(source, targetBinary)
|
||||||
|
} catch {
|
||||||
|
fs.copyFileSync(source, targetBinary)
|
||||||
|
}
|
||||||
|
fs.chmodSync(targetBinary, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinary(name) {
|
||||||
|
const packagePath = require.resolve(`${name}/package.json`)
|
||||||
|
return path.join(path.dirname(packagePath), "bin", sourceBinary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPackage(name) {
|
||||||
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync(
|
||||||
|
"npm",
|
||||||
|
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
|
||||||
|
{ stdio: "inherit", windowsHide: true },
|
||||||
|
)
|
||||||
|
if (result.status !== 0) return false
|
||||||
|
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temp, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyBinary() {
|
||||||
|
return (
|
||||||
|
childProcess.spawnSync(targetBinary, ["--version"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
}).status === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const names = packageNames()
|
||||||
|
for (const name of names) {
|
||||||
|
try {
|
||||||
|
copyBinary(resolveBinary(name))
|
||||||
|
if (verifyBinary()) return
|
||||||
|
} catch {
|
||||||
|
if (installPackage(name) && verifyBinary()) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -32,12 +32,23 @@ async function publishDistribution(input: { root: string; name: string; binary:
|
|||||||
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
||||||
|
|
||||||
await $`mkdir -p ${input.root}/${input.name}/bin`
|
await $`mkdir -p ${input.root}/${input.name}/bin`
|
||||||
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
|
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
|
||||||
|
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
|
||||||
|
[
|
||||||
|
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
|
||||||
|
'echo "" >&2',
|
||||||
|
'echo "This occurs when installation scripts are disabled." >&2',
|
||||||
|
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
|
||||||
|
"exit 1",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
name: input.name,
|
name: input.name,
|
||||||
bin: { [input.binary]: `./bin/${input.binary}` },
|
bin: { [input.binary]: `./bin/${input.binary}.exe` },
|
||||||
|
scripts: { postinstall: "node ./postinstall.mjs" },
|
||||||
version,
|
version,
|
||||||
license: pkg.license,
|
license: pkg.license,
|
||||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
Spec.make("plugin", {
|
||||||
|
description: "Manage plugins",
|
||||||
|
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||||
|
}),
|
||||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { run } from "@opencode-ai/tui"
|
import { run } from "@opencode-ai/tui"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
@@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
: Effect.logInfo(message, tags)
|
: Effect.logInfo(message, tags)
|
||||||
runFork(effect)
|
runFork(effect)
|
||||||
},
|
},
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { EOL } from "node:os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.plugin.commands.list,
|
||||||
|
Effect.fn("cli.plugin.list")(function* () {
|
||||||
|
const options = yield* ServiceConfig.options()
|
||||||
|
const found = yield* Service.discover(options)
|
||||||
|
const endpoint = found ?? (yield* Service.ensure(options))
|
||||||
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||||
|
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
process.stdout.write("No plugins loaded" + EOL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
|||||||
|
|
||||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
|
||||||
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),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Runtime } from "./framework/runtime"
|
|||||||
import { Observability } from "@opencode-ai/core/observability"
|
import { Observability } from "@opencode-ai/core/observability"
|
||||||
import { Updater } from "./services/updater"
|
import { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
@@ -32,6 +31,9 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
auth: () => import("./commands/handlers/mcp/auth"),
|
auth: () => import("./commands/handlers/mcp/auth"),
|
||||||
logout: () => import("./commands/handlers/mcp/logout"),
|
logout: () => import("./commands/handlers/mcp/logout"),
|
||||||
},
|
},
|
||||||
|
plugin: {
|
||||||
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
|
},
|
||||||
migrate: () => import("./commands/handlers/migrate"),
|
migrate: () => import("./commands/handlers/migrate"),
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
@@ -58,7 +60,7 @@ Effect.logInfo("cli starting", {
|
|||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Config.layer),
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||||
Effect.provide(Observability.layer),
|
Effect.provide(Observability.layer),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { ReadStream } from "node:tty"
|
||||||
|
|
||||||
|
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||||
|
|
||||||
|
export type InteractiveStdin = {
|
||||||
|
stdin: NodeJS.ReadStream
|
||||||
|
cleanup(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
type MiniHost = MiniFrontendInput["host"]
|
||||||
|
type ModelState = Record<string, unknown> & {
|
||||||
|
variant?: Record<string, string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(value: unknown): ModelState {
|
||||||
|
if (!isRecord(value)) return {}
|
||||||
|
const variant = isRecord(value.variant)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(value.variant).flatMap(([key, item]) =>
|
||||||
|
typeof item === "string" ? ([[key, item]] as const) : [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
return { ...value, variant }
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantKey(model: NonNullable<MiniFrontendInput["model"]>) {
|
||||||
|
return `${model.providerID}/${model.modelID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferences(statePath: string): MiniHost["preferences"] {
|
||||||
|
const file = path.join(statePath, "model.json")
|
||||||
|
const read = () =>
|
||||||
|
readFile(file, "utf8")
|
||||||
|
.then((value) => state(JSON.parse(value)))
|
||||||
|
.catch(() => state(undefined))
|
||||||
|
return {
|
||||||
|
async resolveVariant(model) {
|
||||||
|
if (!model) return
|
||||||
|
return (await read()).variant?.[variantKey(model)]
|
||||||
|
},
|
||||||
|
async saveVariant(model, variant) {
|
||||||
|
if (!model) return
|
||||||
|
const current = await read()
|
||||||
|
const next = { ...current.variant }
|
||||||
|
if (variant) next[variantKey(model)] = variant
|
||||||
|
if (!variant) delete next[variantKey(model)]
|
||||||
|
await mkdir(path.dirname(file), { recursive: true })
|
||||||
|
.then(() => writeFile(file, JSON.stringify({ ...current, variant: next }, null, 2)))
|
||||||
|
.catch(() => {})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
|
||||||
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
let subscribed = true
|
||||||
|
process.on(name, listener)
|
||||||
|
return () => {
|
||||||
|
if (!subscribed) return
|
||||||
|
subscribed = false
|
||||||
|
process.off(name, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrace(
|
||||||
|
logPath: string,
|
||||||
|
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
|
||||||
|
): MiniHost["diagnostics"]["trace"] {
|
||||||
|
if (!process.env.OPENCODE_DIRECT_TRACE) return
|
||||||
|
const stamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(/\.\d+Z$/, "Z")
|
||||||
|
const target = path.join(logPath, "direct", `${stamp}-${diagnostics.pid}.jsonl`)
|
||||||
|
const text = (data: unknown) =>
|
||||||
|
JSON.stringify(data, (_key, value) => (typeof value === "bigint" ? String(value) : value), 0)
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(logPath, "direct", "latest.json"),
|
||||||
|
text({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
...diagnostics,
|
||||||
|
path: target,
|
||||||
|
}) + "\n",
|
||||||
|
)
|
||||||
|
const trace = {
|
||||||
|
write(type: string, data?: unknown) {
|
||||||
|
fs.appendFileSync(
|
||||||
|
target,
|
||||||
|
text({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
pid: diagnostics.pid,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
}) + "\n",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
trace.write("trace.start", {
|
||||||
|
argv: diagnostics.argv,
|
||||||
|
cwd: diagnostics.cwd,
|
||||||
|
path: target,
|
||||||
|
})
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerminalStdin(target: string): NodeJS.ReadStream {
|
||||||
|
return new ReadStream(fs.openSync(target, "r"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInteractiveStdin(
|
||||||
|
stdin: NodeJS.ReadStream = process.stdin,
|
||||||
|
open: (target: string) => NodeJS.ReadStream = openTerminalStdin,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): InteractiveStdin {
|
||||||
|
if (stdin.isTTY) return { stdin, cleanup() {} }
|
||||||
|
const target = platform === "win32" ? "CONIN$" : "/dev/tty"
|
||||||
|
try {
|
||||||
|
const source = open(target)
|
||||||
|
let cleaned = false
|
||||||
|
return {
|
||||||
|
stdin: source,
|
||||||
|
cleanup() {
|
||||||
|
if (cleaned) return
|
||||||
|
cleaned = true
|
||||||
|
source.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for owner-local resource cleanup tests. */
|
||||||
|
export async function usingInteractiveStdin<T>(
|
||||||
|
run: (terminal: InteractiveStdin) => Promise<T>,
|
||||||
|
resolve: () => InteractiveStdin = resolveInteractiveStdin,
|
||||||
|
) {
|
||||||
|
const terminal = resolve()
|
||||||
|
try {
|
||||||
|
return await run(terminal)
|
||||||
|
} finally {
|
||||||
|
terminal.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for owner-local host capability tests. */
|
||||||
|
export function createMiniHost(input: {
|
||||||
|
terminal: InteractiveStdin
|
||||||
|
directory: string
|
||||||
|
paths?: MiniHost["paths"]
|
||||||
|
}): MiniHost {
|
||||||
|
const paths = input.paths ?? {
|
||||||
|
home: Global.Path.home,
|
||||||
|
state: Global.Path.state,
|
||||||
|
log: Global.Path.log,
|
||||||
|
}
|
||||||
|
const diagnostics = {
|
||||||
|
pid: process.pid,
|
||||||
|
cwd: input.directory,
|
||||||
|
argv: process.argv.slice(2),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
terminal: input.terminal,
|
||||||
|
platform: process.platform,
|
||||||
|
stdout: {
|
||||||
|
write(value) {
|
||||||
|
process.stdout.write(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
readText: (url) => readFile(new URL(url), "utf8"),
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
async open(options) {
|
||||||
|
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||||
|
return openEditor(options)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths,
|
||||||
|
signals: {
|
||||||
|
sigint: signal("SIGINT"),
|
||||||
|
sigusr2: signal("SIGUSR2"),
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
showTiming: Flag.OPENCODE_SHOW_TTFD,
|
||||||
|
now: () => performance.now(),
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
...diagnostics,
|
||||||
|
trace: createTrace(paths.log, diagnostics),
|
||||||
|
},
|
||||||
|
preferences: preferences(paths.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
import { waitForCatalogReady } from "./catalog.shared"
|
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
|
||||||
import type { RunInput, RunTuiConfig } from "./types"
|
|
||||||
import { readStdin } from "../util/io"
|
|
||||||
import { setTimeout } from "node:timers/promises"
|
import { setTimeout } from "node:timers/promises"
|
||||||
|
import { ServerConnection } from "./services/server-connection"
|
||||||
|
import { waitForCatalogReady } from "./services/catalog"
|
||||||
|
import { readStdin } from "./util/io"
|
||||||
|
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||||
|
|
||||||
export type MiniCommandInput = {
|
export type MiniCommandInput = {
|
||||||
server: ServerConnection.Resolved
|
server: ServerConnection.Resolved
|
||||||
@@ -18,59 +18,67 @@ export type MiniCommandInput = {
|
|||||||
replay?: boolean
|
replay?: boolean
|
||||||
replayLimit?: number
|
replayLimit?: number
|
||||||
demo?: boolean
|
demo?: boolean
|
||||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
||||||
export async function runMini(input: MiniCommandInput) {
|
type Model = MiniFrontendInput["model"]
|
||||||
validate(input)
|
|
||||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
|
||||||
const runtimeTask = import("./runtime")
|
|
||||||
const directory = localDirectory()
|
|
||||||
|
|
||||||
|
class MiniInputError extends Error {}
|
||||||
|
|
||||||
|
export async function runMini(input: MiniCommandInput) {
|
||||||
try {
|
try {
|
||||||
const sdk = OpenCode.make({
|
validate(input)
|
||||||
baseUrl: input.server.endpoint.url,
|
const result = await usingInteractiveStdin(async (terminal) => {
|
||||||
headers: Service.headers(input.server.endpoint),
|
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
||||||
})
|
const frontendTask = import("@opencode-ai/tui/mini")
|
||||||
const model = parseModel(input.model)
|
const directory = localDirectory()
|
||||||
let agentTask: Promise<string | undefined> | undefined
|
const sdk = OpenCode.make({
|
||||||
const resolveAgent = () => {
|
baseUrl: input.server.endpoint.url,
|
||||||
agentTask ??= validateAgent(sdk, directory, input.agent)
|
headers: Service.headers(input.server.endpoint),
|
||||||
return agentTask
|
})
|
||||||
}
|
const model = parseModel(input.model)
|
||||||
const resolveSession = async () => {
|
let agentTask: Promise<string | undefined> | undefined
|
||||||
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
const resolveAgent = () => {
|
||||||
const readyModel =
|
agentTask ??= validateAgent(sdk, directory, input.agent)
|
||||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
return agentTask
|
||||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
}
|
||||||
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
const resolveSession = async () => {
|
||||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
||||||
}
|
const readyModel =
|
||||||
const create = (
|
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||||
_sdk: OpenCodeClient,
|
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
||||||
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
|
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
||||||
) => createSession(sdk, directory, next.agent, next.model, next.variant)
|
return { id: session.id, title: session.title, resume: selected !== undefined }
|
||||||
const runtime = await runtimeTask
|
}
|
||||||
await runtime.runInteractiveDeferredMode({
|
const create = (
|
||||||
sdk,
|
_sdk: OpenCodeClient,
|
||||||
directory,
|
next: { agent: string | undefined; model: Model; variant: string | undefined },
|
||||||
resolveAgent,
|
) => createSession(sdk, directory, next.agent, next.model, next.variant)
|
||||||
session: resolveSession,
|
const frontend = await frontendTask
|
||||||
createSession: create,
|
return frontend.runMiniFrontend({
|
||||||
agent: input.agent,
|
host: createMiniHost({ terminal, directory }),
|
||||||
model,
|
sdk,
|
||||||
variant: undefined,
|
directory,
|
||||||
files: [],
|
resolveAgent,
|
||||||
initialInput,
|
session: resolveSession,
|
||||||
thinking: true,
|
createSession: create,
|
||||||
replay: input.replay ?? true,
|
agent: input.agent,
|
||||||
replayLimit: input.replayLimit,
|
model,
|
||||||
demo: input.demo,
|
variant: undefined,
|
||||||
tuiConfig: input.tuiConfig,
|
files: [],
|
||||||
|
initialInput,
|
||||||
|
thinking: true,
|
||||||
|
replay: input.replay ?? true,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
|
demo: input.demo,
|
||||||
|
tuiConfig: input.tuiConfig,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
if (result.exitCode !== 0) process.exit(result.exitCode)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
|
if (error instanceof MiniInputError || (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR))
|
||||||
|
fail(error.message)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,7 +100,6 @@ function validate(input: MiniCommandInput) {
|
|||||||
fail("--replay-limit must be a positive integer")
|
fail("--replay-limit must be a positive integer")
|
||||||
}
|
}
|
||||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||||
resolveInteractiveStdin().cleanup?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDirectory(): string {
|
function localDirectory(): string {
|
||||||
@@ -101,15 +108,15 @@ function localDirectory(): string {
|
|||||||
process.chdir(root)
|
process.chdir(root)
|
||||||
return process.cwd()
|
return process.cwd()
|
||||||
} catch {
|
} catch {
|
||||||
fail(`Failed to change directory to ${root}`)
|
throw new MiniInputError(`Failed to change directory to ${root}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModel(value?: string): RunInput["model"] {
|
function parseModel(value?: string): Model {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
const [providerID, ...rest] = value.split("/")
|
const [providerID, ...rest] = value.split("/")
|
||||||
const modelID = rest.join("/")
|
const modelID = rest.join("/")
|
||||||
if (!providerID || !modelID) fail("--model must use the format provider/model")
|
if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
|
||||||
return { providerID, modelID }
|
return { providerID, modelID }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +151,7 @@ async function selectSession(sdk: OpenCodeClient, directory: string, input: Mini
|
|||||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
||||||
.then((result) => result.data[0])
|
.then((result) => result.data[0])
|
||||||
: undefined)
|
: undefined)
|
||||||
if (input.session && !selected) fail("Session not found")
|
if (input.session && !selected) throw new MiniInputError("Session not found")
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
if (!input.fork) return selected
|
if (!input.fork) return selected
|
||||||
return sdk.session.fork({ sessionID: selected.id })
|
return sdk.session.fork({ sessionID: selected.id })
|
||||||
@@ -154,7 +161,7 @@ async function createSession(
|
|||||||
sdk: OpenCodeClient,
|
sdk: OpenCodeClient,
|
||||||
directory: string,
|
directory: string,
|
||||||
agent: string | undefined,
|
agent: string | undefined,
|
||||||
model: RunInput["model"],
|
model: Model,
|
||||||
variant?: string,
|
variant?: string,
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
if (model) await waitForCatalogReady({ sdk, directory, model })
|
if (model) await waitForCatalogReady({ sdk, directory, model })
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
|
||||||
export {
|
|
||||||
runNonInteractive,
|
|
||||||
mergeInput as mergeNonInteractiveInput,
|
|
||||||
pickRunModel,
|
|
||||||
parseRunModel,
|
|
||||||
type RunCommandInput,
|
|
||||||
} from "./run"
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// Boot-time resolution for direct interactive mode.
|
|
||||||
//
|
|
||||||
// These functions run concurrently at startup to gather everything the runtime
|
|
||||||
// needs before the first frame: TUI keymap config, diff display style,
|
|
||||||
// model variant list with context limits, and session history for the prompt
|
|
||||||
// history ring. All are async because they read config or hit the SDK, but
|
|
||||||
// none block each other.
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
|
||||||
import { loadRunProviders } from "./catalog.shared"
|
|
||||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
|
||||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
|
||||||
import { pickVariant } from "./variant.shared"
|
|
||||||
|
|
||||||
export type ModelInfo = {
|
|
||||||
providers: RunProvider[]
|
|
||||||
variants: string[]
|
|
||||||
limits: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionInfo = {
|
|
||||||
first: boolean
|
|
||||||
history: RunPrompt[]
|
|
||||||
model?: NonNullable<RunInput["model"]>
|
|
||||||
variant: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootService = {
|
|
||||||
readonly resolveModelInfo: (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) => Effect.Effect<ModelInfo>
|
|
||||||
readonly resolveSessionInfo: (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) => Effect.Effect<SessionInfo>
|
|
||||||
}
|
|
||||||
|
|
||||||
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
|
|
||||||
|
|
||||||
function emptyModelInfo(): ModelInfo {
|
|
||||||
return {
|
|
||||||
providers: [],
|
|
||||||
variants: [],
|
|
||||||
limits: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptySessionInfo(): SessionInfo {
|
|
||||||
return {
|
|
||||||
first: true,
|
|
||||||
history: [],
|
|
||||||
variant: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRunTuiConfig(): RunTuiConfig {
|
|
||||||
return {
|
|
||||||
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
|
|
||||||
diff_style: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) {
|
|
||||||
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
|
|
||||||
const limits = Object.fromEntries(
|
|
||||||
providers.flatMap((provider) =>
|
|
||||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
|
||||||
const limit = info?.limit?.context
|
|
||||||
if (typeof limit !== "number" || limit <= 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[`${provider.id}/${modelID}`, limit] as const]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!model) {
|
|
||||||
return {
|
|
||||||
providers,
|
|
||||||
variants: [],
|
|
||||||
limits,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
|
||||||
return {
|
|
||||||
providers,
|
|
||||||
variants: Object.keys(info?.variants ?? {}),
|
|
||||||
limits,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) {
|
|
||||||
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
|
|
||||||
if (!session) {
|
|
||||||
return emptySessionInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
first: session.first,
|
|
||||||
history: sessionHistory(session),
|
|
||||||
model: session.model,
|
|
||||||
variant: pickVariant(model ?? session.model, session),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
resolveModelInfo,
|
|
||||||
resolveSessionInfo,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
|
||||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
|
|
||||||
|
|
||||||
// Fetches available variants and context limits for every provider/model pair.
|
|
||||||
export async function resolveModelInfo(
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
): Promise<ModelInfo> {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
|
||||||
export async function resolveSessionInfo(
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
): Promise<SessionInfo> {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads TUI config once for direct mode keymap setup and display preferences.
|
|
||||||
export async function resolveRunTuiConfig(
|
|
||||||
config?: RunTuiConfig | Promise<RunTuiConfig>,
|
|
||||||
): Promise<RunTuiConfig> {
|
|
||||||
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
|
|
||||||
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
// Lifecycle management for the split-footer renderer.
|
|
||||||
//
|
|
||||||
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
|
|
||||||
// from the terminal palette, writes the entry splash to scrollback, and
|
|
||||||
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
|
|
||||||
// the exit splash and tears everything down in the right order:
|
|
||||||
// footer.close → footer.destroy → renderer shutdown.
|
|
||||||
//
|
|
||||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
|
||||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
|
||||||
import path from "path"
|
|
||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
|
||||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
|
||||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|
||||||
import { resolveRunTheme } from "./theme"
|
|
||||||
import type {
|
|
||||||
FooterApi,
|
|
||||||
PermissionReply,
|
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunAgent,
|
|
||||||
RunInput,
|
|
||||||
RunPrompt,
|
|
||||||
RunReference,
|
|
||||||
RunTuiConfig,
|
|
||||||
} from "./types"
|
|
||||||
import { formatModelLabel } from "./variant.shared"
|
|
||||||
|
|
||||||
const FOOTER_HEIGHT = 4
|
|
||||||
|
|
||||||
type SplashState = {
|
|
||||||
entry: boolean
|
|
||||||
exit: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type CycleResult = {
|
|
||||||
modelLabel?: string
|
|
||||||
status?: string
|
|
||||||
variant?: string | undefined
|
|
||||||
variants?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type FooterLabels = {
|
|
||||||
agentLabel: string
|
|
||||||
modelLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LifecycleInput = {
|
|
||||||
directory: string
|
|
||||||
findFiles: (query: string) => Promise<string[]>
|
|
||||||
agents: RunAgent[]
|
|
||||||
references: RunReference[]
|
|
||||||
sessionID: string
|
|
||||||
sessionTitle?: string
|
|
||||||
getSessionID?: () => string | undefined
|
|
||||||
first: boolean
|
|
||||||
history: RunPrompt[]
|
|
||||||
agent: string | undefined
|
|
||||||
model: RunInput["model"]
|
|
||||||
variant: string | undefined
|
|
||||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
|
||||||
onCycleVariant?: () => CycleResult | void
|
|
||||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
|
||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
|
||||||
onInterrupt?: () => void
|
|
||||||
onBackground?: () => void
|
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Lifecycle = {
|
|
||||||
footer: FooterApi
|
|
||||||
onResize(fn: () => void): () => void
|
|
||||||
refreshTheme(): void
|
|
||||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
|
||||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gracefully tears down the renderer. Order matters: switch external output
|
|
||||||
// back to passthrough before leaving split-footer mode, so pending stdout
|
|
||||||
// doesn't get captured into the now-dead scrollback pipeline.
|
|
||||||
function shutdown(renderer: CliRenderer): void {
|
|
||||||
if (renderer.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderer.externalOutputMode === "capture-stdout") {
|
|
||||||
renderer.externalOutputMode = "passthrough"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderer.screenMode === "split-footer") {
|
|
||||||
renderer.screenMode = "main-screen"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderer.isDestroyed) {
|
|
||||||
renderer.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
|
||||||
if (title && !isDefaultTitle(title)) {
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
showSession: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = history.find((item) => item.text.trim().length > 0)
|
|
||||||
return {
|
|
||||||
title: next?.text ?? title,
|
|
||||||
showSession: !!next,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
|
||||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
|
||||||
return {
|
|
||||||
agentLabel,
|
|
||||||
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function directoryLabel(directory: string) {
|
|
||||||
const resolved = path.resolve(directory)
|
|
||||||
const display =
|
|
||||||
resolved === Global.Path.home
|
|
||||||
? "~"
|
|
||||||
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
|
|
||||||
? resolved.replace(Global.Path.home, "~")
|
|
||||||
: resolved
|
|
||||||
return display.replaceAll("\\", "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueSplash(
|
|
||||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
|
||||||
state: SplashState,
|
|
||||||
phase: keyof SplashState,
|
|
||||||
write: ScrollbackWriter | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (state[phase]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!write) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
state[phase] = true
|
|
||||||
renderer.writeToScrollback(write)
|
|
||||||
renderer.requestRender()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boots the split-footer renderer and constructs the RunFooter.
|
|
||||||
//
|
|
||||||
// The renderer starts in split-footer mode with captured stdout so that
|
|
||||||
// scrollback commits and footer repaints happen in the same frame. After
|
|
||||||
// the entry splash, RunFooter takes over the footer region.
|
|
||||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
|
||||||
const source = resolveInteractiveStdin()
|
|
||||||
const footerTask = import("./footer")
|
|
||||||
try {
|
|
||||||
const renderer = await createCliRenderer({
|
|
||||||
stdin: source.stdin,
|
|
||||||
targetFps: 30,
|
|
||||||
maxFps: 60,
|
|
||||||
useMouse: false,
|
|
||||||
autoFocus: false,
|
|
||||||
openConsoleOnError: false,
|
|
||||||
exitOnCtrlC: false,
|
|
||||||
useKittyKeyboard: { events: process.platform === "win32" },
|
|
||||||
screenMode: "split-footer",
|
|
||||||
footerHeight: FOOTER_HEIGHT,
|
|
||||||
externalOutputMode: "capture-stdout",
|
|
||||||
consoleMode: "disabled",
|
|
||||||
clearOnShutdown: false,
|
|
||||||
})
|
|
||||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
|
||||||
renderer.setBackgroundColor(theme.background)
|
|
||||||
const state: SplashState = {
|
|
||||||
entry: false,
|
|
||||||
exit: false,
|
|
||||||
}
|
|
||||||
const splash = splashInfo(input.sessionTitle, input.history)
|
|
||||||
const meta = splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: input.sessionID,
|
|
||||||
})
|
|
||||||
const labels = footerLabels({
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model,
|
|
||||||
variant: input.variant,
|
|
||||||
})
|
|
||||||
const wrote = queueSplash(
|
|
||||||
renderer,
|
|
||||||
state,
|
|
||||||
"entry",
|
|
||||||
entrySplash({
|
|
||||||
...meta,
|
|
||||||
theme: theme.splash,
|
|
||||||
showSession: splash.showSession,
|
|
||||||
detail: directoryLabel(input.directory),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
|
|
||||||
const { RunFooter } = await footerTask
|
|
||||||
let closed = false
|
|
||||||
let sigintRegistered = false
|
|
||||||
|
|
||||||
const footer = new RunFooter(renderer, {
|
|
||||||
directory: input.directory,
|
|
||||||
findFiles: input.findFiles,
|
|
||||||
agents: input.agents,
|
|
||||||
references: input.references,
|
|
||||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
|
||||||
...labels,
|
|
||||||
model: input.model,
|
|
||||||
variant: input.variant,
|
|
||||||
first: input.first,
|
|
||||||
history: input.history,
|
|
||||||
theme,
|
|
||||||
wrote,
|
|
||||||
tuiConfig,
|
|
||||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
|
||||||
onPermissionReply: input.onPermissionReply,
|
|
||||||
onQuestionReply: input.onQuestionReply,
|
|
||||||
onQuestionReject: input.onQuestionReject,
|
|
||||||
onCycleVariant: input.onCycleVariant,
|
|
||||||
onModelSelect: input.onModelSelect,
|
|
||||||
onVariantSelect: input.onVariantSelect,
|
|
||||||
onInterrupt: input.onInterrupt,
|
|
||||||
onBackground: input.onBackground,
|
|
||||||
onEditorOpen: async ({ value }) => {
|
|
||||||
if (closed || renderer.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { openEditor } = await import("@opencode-ai/tui/editor")
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
const ignore = () => {}
|
|
||||||
detachSigint()
|
|
||||||
process.on("SIGINT", ignore)
|
|
||||||
try {
|
|
||||||
return await openEditor({
|
|
||||||
value,
|
|
||||||
cwd: input.directory,
|
|
||||||
renderer,
|
|
||||||
stdin: source.stdin,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
process.off("SIGINT", ignore)
|
|
||||||
attachSigint()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSubagentSelect: input.onSubagentSelect,
|
|
||||||
onSubagentInterrupt: input.onSubagentInterrupt,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sigint = () => {
|
|
||||||
footer.requestExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachSigint = () => {
|
|
||||||
if (closed || sigintRegistered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", sigint)
|
|
||||||
sigintRegistered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const detachSigint = () => {
|
|
||||||
if (!sigintRegistered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
process.off("SIGINT", sigint)
|
|
||||||
sigintRegistered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
attachSigint()
|
|
||||||
|
|
||||||
const close = async (next: {
|
|
||||||
showExit: boolean
|
|
||||||
sessionTitle?: string
|
|
||||||
sessionID?: string
|
|
||||||
history?: RunPrompt[]
|
|
||||||
}) => {
|
|
||||||
if (closed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closed = true
|
|
||||||
detachSigint()
|
|
||||||
let wroteExit = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
await footer.idle().catch(() => {})
|
|
||||||
|
|
||||||
const show = renderer.isDestroyed ? false : next.showExit
|
|
||||||
if (!renderer.isDestroyed && show) {
|
|
||||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
|
||||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
|
||||||
wroteExit = queueSplash(
|
|
||||||
renderer,
|
|
||||||
state,
|
|
||||||
"exit",
|
|
||||||
exitSplash({
|
|
||||||
...splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: sessionID,
|
|
||||||
}),
|
|
||||||
theme: footer.currentTheme().splash,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
footer.close()
|
|
||||||
await footer.idle().catch(() => {})
|
|
||||||
footer.destroy()
|
|
||||||
shutdown(renderer)
|
|
||||||
if (!wroteExit) {
|
|
||||||
process.stdout.write("\n")
|
|
||||||
}
|
|
||||||
source.cleanup?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
footer,
|
|
||||||
refreshTheme() {
|
|
||||||
footer.refreshTheme()
|
|
||||||
},
|
|
||||||
onResize(fn) {
|
|
||||||
let width = renderer.terminalWidth
|
|
||||||
let height = renderer.terminalHeight
|
|
||||||
const resize = () => {
|
|
||||||
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
width = renderer.terminalWidth
|
|
||||||
height = renderer.terminalHeight
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
renderer.on(CliRenderEvents.RESIZE, resize)
|
|
||||||
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
|
||||||
},
|
|
||||||
async resetForReplay(next) {
|
|
||||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
|
||||||
throw new Error("runtime closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
await footer.idle()
|
|
||||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
|
||||||
throw new Error("runtime closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
footer.resetForReplay(true)
|
|
||||||
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
|
||||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
|
||||||
renderer.writeToScrollback(
|
|
||||||
entrySplash({
|
|
||||||
...splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
|
||||||
}),
|
|
||||||
theme: footer.currentTheme().splash,
|
|
||||||
showSession: splash.showSession,
|
|
||||||
detail: directoryLabel(input.directory),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
renderer.requestRender()
|
|
||||||
},
|
|
||||||
close,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
source.cleanup?.()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import * as tty from "node:tty"
|
|
||||||
|
|
||||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
|
||||||
|
|
||||||
type InteractiveStdin = {
|
|
||||||
stdin: NodeJS.ReadStream
|
|
||||||
cleanup?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function openTerminalStdin(path: string): NodeJS.ReadStream {
|
|
||||||
return new tty.ReadStream(fs.openSync(path, "r"))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveInteractiveStdin(
|
|
||||||
stdin: NodeJS.ReadStream = process.stdin,
|
|
||||||
open: (path: string) => NodeJS.ReadStream = openTerminalStdin,
|
|
||||||
platform = process.platform,
|
|
||||||
): InteractiveStdin {
|
|
||||||
if (stdin.isTTY) {
|
|
||||||
return { stdin }
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = platform === "win32" ? "CONIN$" : "/dev/tty"
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = open(file)
|
|
||||||
return {
|
|
||||||
stdin: stream,
|
|
||||||
cleanup: () => {
|
|
||||||
stream.destroy()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Dev-only JSONL event trace for direct interactive mode.
|
|
||||||
//
|
|
||||||
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
|
|
||||||
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
|
|
||||||
// a latest.json pointer so you can quickly find the most recent trace.
|
|
||||||
//
|
|
||||||
// The trace captures the full closed loop: outbound prompts, inbound SDK
|
|
||||||
// events, reducer output, footer commits, and turn lifecycle markers.
|
|
||||||
// Useful for debugging stream ordering, permission behavior, and
|
|
||||||
// footer/transcript mismatches.
|
|
||||||
//
|
|
||||||
// Lazy-initialized: the first call to trace() decides whether tracing is
|
|
||||||
// active based on the env var, and subsequent calls return the cached result.
|
|
||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
|
|
||||||
export type Trace = {
|
|
||||||
write(type: string, data?: unknown): void
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: Trace | false | undefined
|
|
||||||
|
|
||||||
function stamp() {
|
|
||||||
return new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[-:]/g, "")
|
|
||||||
.replace(/\.\d+Z$/, "Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
function file() {
|
|
||||||
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function latest() {
|
|
||||||
return path.join(Global.Path.log, "direct", "latest.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(data: unknown) {
|
|
||||||
return JSON.stringify(
|
|
||||||
data,
|
|
||||||
(_key, value) => {
|
|
||||||
if (typeof value === "bigint") {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trace(): Trace | undefined {
|
|
||||||
if (state !== undefined) {
|
|
||||||
return state || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.OPENCODE_DIRECT_TRACE) {
|
|
||||||
state = false
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = file()
|
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
||||||
fs.writeFileSync(
|
|
||||||
latest(),
|
|
||||||
text({
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
pid: process.pid,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
argv: process.argv.slice(2),
|
|
||||||
path: target,
|
|
||||||
}) + "\n",
|
|
||||||
)
|
|
||||||
state = {
|
|
||||||
write(type: string, data?: unknown) {
|
|
||||||
fs.appendFileSync(
|
|
||||||
target,
|
|
||||||
text({
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
pid: process.pid,
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
}) + "\n",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
state.write("trace.start", {
|
|
||||||
argv: process.argv.slice(2),
|
|
||||||
cwd: process.cwd(),
|
|
||||||
path: target,
|
|
||||||
})
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
// Model variant resolution and persistence.
|
|
||||||
//
|
|
||||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
|
||||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
|
||||||
//
|
|
||||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
|
||||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
|
||||||
// variant and the persisted file.
|
|
||||||
import path from "path"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
|
||||||
import type { RunInput, RunProvider } from "./types"
|
|
||||||
|
|
||||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
|
||||||
|
|
||||||
type ModelState = Record<string, unknown> & {
|
|
||||||
variant?: Record<string, string | undefined>
|
|
||||||
}
|
|
||||||
type VariantService = {
|
|
||||||
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
|
|
||||||
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
type VariantRuntime = {
|
|
||||||
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
|
|
||||||
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
|
|
||||||
|
|
||||||
function modelKey(provider: string, model: string): string {
|
|
||||||
return `${provider}/${model}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
|
||||||
return modelKey(model.providerID, model.modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
|
|
||||||
const provider = providers?.find((item) => item.id === model.providerID)
|
|
||||||
return {
|
|
||||||
provider: provider?.name ?? model.providerID,
|
|
||||||
model: provider?.models[model.modelID]?.name ?? model.modelID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatModelLabel(
|
|
||||||
model: NonNullable<RunInput["model"]>,
|
|
||||||
variant: string | undefined,
|
|
||||||
providers?: RunProvider[],
|
|
||||||
): string {
|
|
||||||
const names = modelInfo(providers, model)
|
|
||||||
const label = variant ? ` · ${variant}` : ""
|
|
||||||
return `${names.model} · ${names.provider}${label}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
|
||||||
if (variants.length === 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
return variants[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = variants.indexOf(current)
|
|
||||||
if (idx === -1 || idx === variants.length - 1) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants[idx + 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
|
||||||
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variants.length === 0 || variants.includes(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
|
||||||
// history. fitVariant() checks saved and session values against the available
|
|
||||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
|
||||||
export function resolveVariant(
|
|
||||||
input: string | undefined,
|
|
||||||
session: string | undefined,
|
|
||||||
saved: string | undefined,
|
|
||||||
variants: string[],
|
|
||||||
): string | undefined {
|
|
||||||
if (input !== undefined) {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = fitVariant(saved, variants)
|
|
||||||
const current = fitVariant(session, variants)
|
|
||||||
if (current !== undefined) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function state(value: unknown): ModelState {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant = isRecord(value.variant)
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(value.variant).flatMap(([key, item]) => {
|
|
||||||
if (typeof item !== "string") {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[key, item] as const]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
variant,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = Layer.fresh(
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const file = yield* FSUtil.Service
|
|
||||||
|
|
||||||
const read = Effect.fn("RunVariant.read")(function* () {
|
|
||||||
return yield* file.readJson(MODEL_FILE).pipe(
|
|
||||||
Effect.map(state),
|
|
||||||
Effect.catchCause(() => Effect.succeed(state(undefined))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
|
||||||
if (!model) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (yield* read()).variant?.[variantKey(model)]
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
|
|
||||||
model: RunInput["model"],
|
|
||||||
variant: string | undefined,
|
|
||||||
) {
|
|
||||||
if (!model) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = yield* read()
|
|
||||||
const next = {
|
|
||||||
...current.variant,
|
|
||||||
}
|
|
||||||
const key = variantKey(model)
|
|
||||||
if (variant) {
|
|
||||||
next[key] = variant
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!variant) {
|
|
||||||
delete next[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* file
|
|
||||||
.writeJson(MODEL_FILE, {
|
|
||||||
...current,
|
|
||||||
variant: next,
|
|
||||||
})
|
|
||||||
.pipe(Effect.orElseSucceed(() => undefined))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
resolveSavedVariant,
|
|
||||||
saveVariant,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
|
|
||||||
|
|
||||||
/** @internal Exported for testing. */
|
|
||||||
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
|
|
||||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
|
|
||||||
return {
|
|
||||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
|
||||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = createVariantRuntime()
|
|
||||||
|
|
||||||
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
|
||||||
return runtime.resolveSavedVariant(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
|
||||||
void runtime.saveVariant(model, variant)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { runNonInteractive, type RunCommandInput } from "./run"
|
||||||
|
export { runV1Bridge, type V1RunCommandInput } from "./v1"
|
||||||
@@ -2,8 +2,8 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
import type { MiniToolPart } from "./types"
|
|
||||||
|
|
||||||
type Model = {
|
type Model = {
|
||||||
providerID: string
|
providerID: string
|
||||||
@@ -29,6 +29,7 @@ type Input = {
|
|||||||
auto: boolean
|
auto: boolean
|
||||||
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
|
||||||
attached: boolean
|
attached: boolean
|
||||||
|
compatibility?: "v1"
|
||||||
renderTool: (part: MiniToolPart) => Promise<void>
|
renderTool: (part: MiniToolPart) => Promise<void>
|
||||||
renderToolError: (part: MiniToolPart) => Promise<void>
|
renderToolError: (part: MiniToolPart) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
let permissionRejected = false
|
let permissionRejected = false
|
||||||
let formCancelled = false
|
let formCancelled = false
|
||||||
let interrupted = false
|
let interrupted = false
|
||||||
|
let v1InvalidOutput = false
|
||||||
let admission: AbortController | undefined
|
let admission: AbortController | undefined
|
||||||
|
let pendingStep: { timestamp: number; part: Record<string, unknown>; label: string } | undefined
|
||||||
|
|
||||||
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
|
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
|
||||||
if (input.format !== "json") return false
|
if (input.format !== "json") return false
|
||||||
@@ -91,6 +94,17 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
UI.empty()
|
UI.empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushStep = () => {
|
||||||
|
if (!pendingStep) return
|
||||||
|
const value = pendingStep
|
||||||
|
pendingStep = undefined
|
||||||
|
if (!emit("step_start", value.timestamp, { part: value.part }) && input.format !== "json") {
|
||||||
|
UI.empty()
|
||||||
|
UI.println(value.label)
|
||||||
|
UI.empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||||
if (!input.auto) {
|
if (!input.auto) {
|
||||||
permissionRejected = true
|
permissionRejected = true
|
||||||
@@ -177,6 +191,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
type: "step-start",
|
type: "step-start",
|
||||||
snapshot: event.data.snapshot,
|
snapshot: event.data.snapshot,
|
||||||
}
|
}
|
||||||
|
if (input.compatibility === "v1") {
|
||||||
|
pendingStep = {
|
||||||
|
timestamp: time,
|
||||||
|
part,
|
||||||
|
label: `> ${event.data.agent} · ${event.data.model.id}`,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!emit("step_start", time, { part }) && input.format !== "json") {
|
if (!emit("step_start", time, { part }) && input.format !== "json") {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
|
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
|
||||||
@@ -186,6 +208,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.text.started") {
|
if (event.type === "session.text.started") {
|
||||||
|
flushStep()
|
||||||
starts.set("text", { id: partID(event.id), timestamp: time })
|
starts.set("text", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -205,6 +228,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.reasoning.started") {
|
if (event.type === "session.reasoning.started") {
|
||||||
|
flushStep()
|
||||||
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
starts.set("reasoning", { id: partID(event.id), timestamp: time })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -235,6 +259,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
|
flushStep()
|
||||||
tools.set(event.data.callID, {
|
tools.set(event.data.callID, {
|
||||||
id: partID(event.id),
|
id: partID(event.id),
|
||||||
timestamp: time,
|
timestamp: time,
|
||||||
@@ -250,6 +275,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
|
flushStep()
|
||||||
const current = tools.get(event.data.callID)
|
const current = tools.get(event.data.callID)
|
||||||
tools.set(event.data.callID, {
|
tools.set(event.data.callID, {
|
||||||
id: current?.id ?? partID(event.id),
|
id: current?.id ?? partID(event.id),
|
||||||
@@ -274,10 +300,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: current.input,
|
input: current.input,
|
||||||
output: event.data.content
|
output: toolOutputText(current.tool, event.data.content),
|
||||||
.filter((item) => item.type === "text")
|
|
||||||
.map((item) => item.text)
|
|
||||||
.join("\n"),
|
|
||||||
title: current.tool,
|
title: current.tool,
|
||||||
metadata: {
|
metadata: {
|
||||||
structured: event.data.structured,
|
structured: event.data.structured,
|
||||||
@@ -318,6 +341,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
tools.delete(event.data.callID)
|
tools.delete(event.data.callID)
|
||||||
|
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
|
||||||
if (!emit("tool_use", time, { part })) {
|
if (!emit("tool_use", time, { part })) {
|
||||||
await input.renderToolError(part)
|
await input.renderToolError(part)
|
||||||
UI.error(error)
|
UI.error(error)
|
||||||
@@ -326,6 +350,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "session.step.ended") {
|
if (event.type === "session.step.ended") {
|
||||||
|
flushStep()
|
||||||
const part = {
|
const part = {
|
||||||
id: partID(event.id),
|
id: partID(event.id),
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
@@ -340,13 +365,28 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.step.failed") {
|
if (event.type === "session.step.failed") {
|
||||||
|
if (
|
||||||
|
input.compatibility === "v1" &&
|
||||||
|
event.data.error.message === "Provider stream ended without a terminal finish event"
|
||||||
|
) {
|
||||||
|
pendingStep = undefined
|
||||||
|
v1InvalidOutput = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
|
||||||
|
flushStep()
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
|
if (
|
||||||
|
input.compatibility === "v1" &&
|
||||||
|
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
flushStep()
|
||||||
if (!emittedError && !questionRejected && !formCancelled) {
|
if (!emittedError && !questionRejected && !formCancelled) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
@@ -355,6 +395,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.interrupted") {
|
if (event.type === "session.execution.interrupted") {
|
||||||
|
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
|
||||||
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||||
if (event.data.reason !== "user" && !emittedError) {
|
if (event.data.reason !== "user" && !emittedError) {
|
||||||
emittedError = true
|
emittedError = true
|
||||||
@@ -480,7 +521,7 @@ async function prepareFile(file: File) {
|
|||||||
const uri = file.url.startsWith("data:")
|
const uri = file.url.startsWith("data:")
|
||||||
? file.url
|
? file.url
|
||||||
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
: `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}`
|
||||||
return { attachment: { uri, mime: file.mime, name: file.filename } }
|
return { attachment: { uri, name: file.filename } }
|
||||||
}
|
}
|
||||||
const content = file.url.startsWith("data:")
|
const content = file.url.startsWith("data:")
|
||||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||||
@@ -6,10 +6,9 @@ import { open } from "node:fs/promises"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { readStdin } from "../util/io"
|
import { readStdin } from "../util/io"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
import { ServerConnection } from "../services/server-connection"
|
||||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
import { waitForCatalogReady } from "../services/catalog"
|
||||||
|
import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
import { toolInlineInfo } from "./tool"
|
|
||||||
import type { MiniToolPart } from "./types"
|
|
||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
export type RunCommandInput = {
|
export type RunCommandInput = {
|
||||||
@@ -39,24 +38,39 @@ type Prepared = {
|
|||||||
files: FilePart[]
|
files: FilePart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecutionOptions = {
|
||||||
|
root?: string
|
||||||
|
directory?: string
|
||||||
|
useServerDirectory?: boolean
|
||||||
|
variant?: string
|
||||||
|
attached?: boolean
|
||||||
|
compatibility?: "v1"
|
||||||
|
}
|
||||||
|
|
||||||
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
export function runNonInteractive(input: RunCommandInput) {
|
export function runNonInteractive(input: RunCommandInput) {
|
||||||
return run(input).catch((error) => reportError(input, error instanceof Error ? error.message : String(error)))
|
return runNonInteractiveWithOptions(input, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(input: RunCommandInput) {
|
/** @internal Used only by the V1 command boundary. */
|
||||||
|
export function runNonInteractiveWithOptions(input: RunCommandInput, options: ExecutionOptions) {
|
||||||
|
return run(input, options).catch((error) => reportRunError(input, errorMessage(error)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(input: RunCommandInput, options: ExecutionOptions) {
|
||||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||||
const root = process.env.PWD ?? process.cwd()
|
const root = options.root ?? process.env.PWD ?? process.cwd()
|
||||||
const directory = localDirectory(root)
|
const local = localDirectory(root)
|
||||||
|
const directory = options.useServerDirectory ? undefined : (options.directory ?? local)
|
||||||
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
|
const message = mergeInput(formatMessage(input.message), process.stdin.isTTY ? undefined : await readStdin())
|
||||||
if (!message?.trim()) fail("You must provide a message")
|
if (!message?.trim()) fail("You must provide a message")
|
||||||
const files = await Promise.all(input.file.map((file) => prepareFile(file, root)))
|
const files = await Promise.all(input.file.map((file) => prepareFile(file, root, options)))
|
||||||
const prepared = { directory, message, files }
|
const prepared = { directory, message, files }
|
||||||
return execute(input, prepared, input.server.endpoint)
|
return execute(input, prepared, input.server.endpoint, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
|
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||||
@@ -65,7 +79,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
const workspace = session?.location.workspaceID
|
const workspace = session?.location.workspaceID
|
||||||
const explicit = parseRunModel(input.model)
|
const explicit = parseRunModel(input.model)
|
||||||
const explicitModel = explicit?.model
|
const explicitModel = explicit?.model
|
||||||
const variant = explicit?.variant
|
const variant = options.variant ?? explicit?.variant
|
||||||
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
|
||||||
const defaultModel =
|
const defaultModel =
|
||||||
!explicitModel && !sessionModel
|
!explicitModel && !sessionModel
|
||||||
@@ -74,12 +88,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||||
: undefined
|
: undefined
|
||||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
||||||
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||||
if (model) {
|
if (model) {
|
||||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
|
||||||
return reportError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
|
||||||
}
|
}
|
||||||
const agent = await validateAgent(client, cwd, input.agent)
|
const agent = await validateAgent(client, cwd, input.agent)
|
||||||
const selected =
|
const selected =
|
||||||
@@ -107,10 +121,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
|||||||
thinking: input.thinking ?? false,
|
thinking: input.thinking ?? false,
|
||||||
format: input.format,
|
format: input.format,
|
||||||
auto: input.auto ?? false,
|
auto: input.auto ?? false,
|
||||||
attached: true,
|
attached: options.attached ?? true,
|
||||||
|
compatibility: options.compatibility,
|
||||||
renderTool,
|
renderTool,
|
||||||
renderToolError,
|
renderToolError,
|
||||||
}).catch((error) => reportError(input, error instanceof Error ? error.message : String(error), selected.id))
|
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||||
@@ -155,7 +170,10 @@ export function parseRunModel(value?: string) {
|
|||||||
|
|
||||||
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
|
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const agents = await loadRunAgents(client, directory).catch(() => undefined)
|
const agents = await client.agent
|
||||||
|
.list({ location: { directory } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch(() => undefined)
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
warning("failed to list agents. Falling back to default agent")
|
warning("failed to list agents. Falling back to default agent")
|
||||||
return
|
return
|
||||||
@@ -185,11 +203,13 @@ async function selectSession(client: OpenCodeClient, directory: string, input: R
|
|||||||
return client.session.fork({ sessionID: selected.id })
|
return client.session.fork({ sessionID: selected.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareFile(input: string, directory: string): Promise<FilePart> {
|
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
|
||||||
const file = path.resolve(directory, input)
|
const file = path.resolve(directory, input)
|
||||||
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
|
||||||
try {
|
try {
|
||||||
const stat = await handle.stat()
|
const stat = await handle.stat()
|
||||||
|
if (options.compatibility === "v1" && options.attached && stat.isDirectory())
|
||||||
|
fail(`Cannot attach local directory without a shared filesystem: ${input}`)
|
||||||
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
if (!stat.isFile() || stat.size > ATTACH_FILE_MAX_BYTES)
|
||||||
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
fail(`Cannot attach a directory, special file, or file larger than 10 MiB: ${input}`)
|
||||||
const content = Buffer.alloc(Number(stat.size))
|
const content = Buffer.alloc(Number(stat.size))
|
||||||
@@ -249,7 +269,15 @@ function warning(message: string) {
|
|||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportError(input: RunCommandInput, message: string, sessionID?: string) {
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||||
|
return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Used by the V1 command boundary before a Session exists. */
|
||||||
|
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
|
||||||
process.exitCode = 1
|
process.exitCode = 1
|
||||||
if (input.format === "json") {
|
if (input.format === "json") {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import path from "node:path"
|
||||||
|
import { Standalone } from "../services/standalone"
|
||||||
|
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
|
||||||
|
|
||||||
|
export type V1RunCommandInput = {
|
||||||
|
message: string[]
|
||||||
|
continue?: boolean
|
||||||
|
session?: string
|
||||||
|
fork?: boolean
|
||||||
|
model?: string
|
||||||
|
agent?: string
|
||||||
|
format: "default" | "json"
|
||||||
|
file: string[]
|
||||||
|
title?: string
|
||||||
|
server?: string
|
||||||
|
password?: string
|
||||||
|
username?: string
|
||||||
|
directory?: string
|
||||||
|
variant?: string
|
||||||
|
thinking?: boolean
|
||||||
|
dangerouslySkipPermissions?: boolean
|
||||||
|
standaloneCommand?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runV1Bridge(input: V1RunCommandInput) {
|
||||||
|
const root = process.env.PWD ?? process.cwd()
|
||||||
|
const attached = input.server !== undefined
|
||||||
|
const local = !attached && input.directory ? path.resolve(root, input.directory) : root
|
||||||
|
try {
|
||||||
|
process.chdir(local)
|
||||||
|
} catch {
|
||||||
|
reportRunError(input, `Failed to change directory to ${local}`)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const endpoint = attached
|
||||||
|
? explicitEndpoint(input)
|
||||||
|
: yield* Standalone.start({ command: input.standaloneCommand })
|
||||||
|
yield* Effect.promise(() =>
|
||||||
|
runNonInteractiveWithOptions(nativeInput(input, endpoint), {
|
||||||
|
root: local,
|
||||||
|
directory: attached ? input.directory : local,
|
||||||
|
useServerDirectory: attached && input.directory === undefined,
|
||||||
|
variant: input.variant,
|
||||||
|
attached,
|
||||||
|
compatibility: "v1",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
|
||||||
|
return {
|
||||||
|
server: { endpoint },
|
||||||
|
message: input.message,
|
||||||
|
continue: input.continue,
|
||||||
|
session: input.session,
|
||||||
|
fork: input.fork,
|
||||||
|
model: input.model,
|
||||||
|
agent: input.agent,
|
||||||
|
format: input.format,
|
||||||
|
file: input.file,
|
||||||
|
title: input.title,
|
||||||
|
thinking: input.thinking,
|
||||||
|
auto: input.dangerouslySkipPermissions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitEndpoint(input: V1RunCommandInput): Endpoint {
|
||||||
|
const url = input.server
|
||||||
|
if (!url) throw new Error("Missing V1 server URL")
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
auth: input.password
|
||||||
|
? { type: "basic", username: input.username ?? "opencode", password: input.password }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
export * as ServerProcess from "./server-process"
|
export * as ServerProcess from "./server-process"
|
||||||
|
|
||||||
import { NodeServices } from "@effect/platform-node"
|
import { NodeServices } from "@effect/platform-node"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
|
|
||||||
import { randomBytes, randomUUID } from "node:crypto"
|
import { randomBytes, randomUUID } from "node:crypto"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||||
@@ -28,7 +26,7 @@ export type Options = {
|
|||||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||||
return yield* processEffect(options).pipe(
|
return yield* processEffect(options).pipe(
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]))),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -38,14 +36,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||||
if (serviceOptions !== undefined) {
|
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||||
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
|
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||||
Effect.as(true),
|
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||||
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
|
if (
|
||||||
)
|
serviceOptions !== undefined &&
|
||||||
if (!acquired) return yield* Effect.void
|
port !== undefined &&
|
||||||
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
|
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
|
||||||
}
|
)
|
||||||
|
return
|
||||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||||
const environmentPassword = yield* Env.password
|
const environmentPassword = yield* Env.password
|
||||||
// Keep the lease credential out of the environment inherited by tools.
|
// Keep the lease credential out of the environment inherited by tools.
|
||||||
@@ -53,25 +52,49 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
delete process.env.OPENCODE_PASSWORD
|
delete process.env.OPENCODE_PASSWORD
|
||||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||||
}
|
}
|
||||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
|
||||||
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")
|
||||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||||
const instanceID = randomUUID()
|
const instanceID = randomUUID()
|
||||||
const server = yield* start({
|
const server = yield* start({
|
||||||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
hostname,
|
||||||
port: Option.fromNullishOr(options.port ?? config.port),
|
port: Option.fromNullishOr(port),
|
||||||
password,
|
password,
|
||||||
instanceID,
|
instanceID,
|
||||||
service:
|
service:
|
||||||
serviceOptions === undefined
|
serviceOptions === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
|
: {
|
||||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
onListen: (address, shutdown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!config.password) yield* ServiceConfig.password(password)
|
||||||
|
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(Logger.layer([], { mergeWithExisting: false })),
|
||||||
|
Effect.catch((error) => {
|
||||||
|
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||||
|
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||||
|
Effect.flatMap((found) =>
|
||||||
|
found
|
||||||
|
? Effect.void
|
||||||
|
: Effect.fail(
|
||||||
|
new Error(
|
||||||
|
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||||
|
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||||
|
{ cause: error },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (server === undefined) return
|
||||||
const url = HttpServer.formatAddress(server.address)
|
const url = HttpServer.formatAddress(server.address)
|
||||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||||
@@ -95,6 +118,7 @@ const register = Effect.fnUntraced(function* (
|
|||||||
password: string,
|
password: string,
|
||||||
id: string,
|
id: string,
|
||||||
file: string,
|
file: string,
|
||||||
|
shutdown: Effect.Effect<void>,
|
||||||
) {
|
) {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const temp = file + "." + id + ".tmp"
|
const temp = file + "." + id + ".tmp"
|
||||||
@@ -107,38 +131,49 @@ const register = Effect.fnUntraced(function* (
|
|||||||
password,
|
password,
|
||||||
}
|
}
|
||||||
const encoded = yield* encodeInfo(info)
|
const encoded = yield* encodeInfo(info)
|
||||||
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
|
||||||
yield* publish
|
|
||||||
const current = fs.readFileString(file).pipe(
|
const current = fs.readFileString(file).pipe(
|
||||||
Effect.flatMap(decodeInfo),
|
Effect.flatMap(decodeInfo),
|
||||||
Effect.orElseSucceed(() => undefined),
|
Effect.orElseSucceed(() => undefined),
|
||||||
)
|
)
|
||||||
const assertRegistration = Effect.gen(function* () {
|
const owns = (found: Info | undefined) =>
|
||||||
const found = yield* current
|
found?.id === info.id &&
|
||||||
if (
|
found.version === info.version &&
|
||||||
found !== undefined &&
|
found.url === info.url &&
|
||||||
found.id === info.id &&
|
found.pid === info.pid &&
|
||||||
found.version === info.version &&
|
found.password === info.password
|
||||||
found.url === info.url &&
|
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||||
found.pid === info.pid &&
|
yield* current.pipe(
|
||||||
found.password === info.password
|
Effect.filterOrFail(owns),
|
||||||
)
|
|
||||||
return
|
|
||||||
yield* publish
|
|
||||||
})
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
current.pipe(
|
|
||||||
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
|
|
||||||
Effect.ignore,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
yield* assertRegistration.pipe(
|
|
||||||
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
|
|
||||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||||
|
Effect.ignore,
|
||||||
|
Effect.andThen(shutdown),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
)
|
)
|
||||||
|
return current.pipe(
|
||||||
|
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
|
||||||
|
Effect.ignore,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||||
|
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||||
|
Effect.filterOrFail((value) => value !== undefined),
|
||||||
|
Effect.retry(Schedule.spaced("100 millis")),
|
||||||
|
Effect.timeoutOption("15 seconds"),
|
||||||
|
)
|
||||||
|
return Option.isSome(found)
|
||||||
|
})
|
||||||
|
|
||||||
|
function serviceURL(hostname: string, port: number) {
|
||||||
|
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressInUse(error: unknown): boolean {
|
||||||
|
if (typeof error !== "object" || error === null) return false
|
||||||
|
if ("code" in error && error.code === "EADDRINUSE") return true
|
||||||
|
return "cause" in error && addressInUse(error.cause)
|
||||||
|
}
|
||||||
|
|
||||||
function waitForStdinClose() {
|
function waitForStdinClose() {
|
||||||
return Effect.callback<void>((resume) => {
|
return Effect.callback<void>((resume) => {
|
||||||
const close = () => resume(Effect.void)
|
const close = () => resume(Effect.void)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
// Location plugins initialize asynchronously, so explicit model selection must
|
||||||
|
// wait for that exact model before prompt admission. The execution path owns
|
||||||
|
// the authoritative error if readiness times out.
|
||||||
|
export async function waitForCatalogReady(input: {
|
||||||
|
sdk: OpenCodeClient
|
||||||
|
directory: string
|
||||||
|
workspace?: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
timeoutMs?: number
|
||||||
|
}) {
|
||||||
|
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const models = await input.sdk.model
|
||||||
|
.list({ location: { directory: input.directory, workspace: input.workspace } })
|
||||||
|
.then((result) => result.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ export function filename(channel = InstallationChannel) {
|
|||||||
return `service-${Hash.fast(channel)}.json`
|
return `service-${Hash.fast(channel)}.json`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defaultPort(channel = InstallationChannel) {
|
||||||
|
if (channel === "latest") return 0xc0de
|
||||||
|
if (channel === "local") return 0xc0df
|
||||||
|
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||||
|
}
|
||||||
|
|
||||||
export function versionBelongsToChannel(
|
export function versionBelongsToChannel(
|
||||||
version: string | undefined,
|
version: string | undefined,
|
||||||
channel = InstallationChannel,
|
channel = InstallationChannel,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Deferred, Effect, Schema, Stream } from "effect"
|
import { Deferred, Effect, Schema, Stream } from "effect"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
@@ -53,7 +53,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
|||||||
pid: proc.pid,
|
pid: proc.pid,
|
||||||
} satisfies Endpoint & { readonly pid: number }
|
} satisfies Endpoint & { readonly pid: number }
|
||||||
},
|
},
|
||||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function start(options: Options = {}) {
|
export function start(options: Options = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { defineScript } from "opencode-drive"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
export default defineScript({
|
||||||
|
launch: "manual",
|
||||||
|
setup({ config }) {
|
||||||
|
config.autoupdate = false
|
||||||
|
},
|
||||||
|
async run({ artifacts, llm, server, signal }) {
|
||||||
|
await configureServicePort(artifacts)
|
||||||
|
await server.launch()
|
||||||
|
|
||||||
|
const registration = await serviceRegistration(artifacts)
|
||||||
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
|
const session = `mini-stage2-${process.pid}`
|
||||||
|
const snapshots = path.join(artifacts, "mini-stage2")
|
||||||
|
await mkdir(snapshots, { recursive: true })
|
||||||
|
|
||||||
|
llm.queue(
|
||||||
|
llm.toolCall({
|
||||||
|
index: 0,
|
||||||
|
id: "mini-shell",
|
||||||
|
name: "shell",
|
||||||
|
input: { command: "printf 'drive-mini-tool-output\\n'" },
|
||||||
|
}),
|
||||||
|
llm.finish("tool-calls"),
|
||||||
|
)
|
||||||
|
llm.queue(llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
void tmux(["kill-session", "-t", session], true).catch(() => {})
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true })
|
||||||
|
try {
|
||||||
|
await tmux([
|
||||||
|
"new-session",
|
||||||
|
"-d",
|
||||||
|
"-s",
|
||||||
|
session,
|
||||||
|
"-x",
|
||||||
|
"140",
|
||||||
|
"-y",
|
||||||
|
"30",
|
||||||
|
"--",
|
||||||
|
"env",
|
||||||
|
`PWD=${path.join(artifacts, "files")}`,
|
||||||
|
`OPENCODE_PASSWORD=${registration.password}`,
|
||||||
|
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||||
|
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||||
|
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||||
|
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||||
|
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||||
|
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||||
|
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||||
|
"OPENCODE_DIRECT_TRACE=1",
|
||||||
|
process.execPath,
|
||||||
|
"--conditions=browser",
|
||||||
|
"--preload=@opentui/solid/preload",
|
||||||
|
path.join(root, "packages/cli/src/index.ts"),
|
||||||
|
"mini",
|
||||||
|
"--server",
|
||||||
|
registration.url,
|
||||||
|
"--model",
|
||||||
|
"simulation/gpt-sim-model",
|
||||||
|
])
|
||||||
|
await tmux(["set-option", "-t", session, "remain-on-exit", "on"])
|
||||||
|
|
||||||
|
const first = await waitForPane(session, "OpenCode")
|
||||||
|
await Bun.write(path.join(snapshots, "01-first-paint.txt"), first)
|
||||||
|
if (first.includes("drive mini response complete")) throw new Error("response rendered before prompt submission")
|
||||||
|
|
||||||
|
await waitForPane(session, "Simulated Model", 15_000)
|
||||||
|
await tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"])
|
||||||
|
await Bun.sleep(100)
|
||||||
|
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||||
|
const completed = await waitForPane(session, "drive mini response complete", 20_000)
|
||||||
|
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
|
||||||
|
await Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed)
|
||||||
|
|
||||||
|
await Bun.sleep(500)
|
||||||
|
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
|
||||||
|
await tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`])
|
||||||
|
await tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"])
|
||||||
|
await waitForFile(
|
||||||
|
resizeOutput,
|
||||||
|
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
|
||||||
|
)
|
||||||
|
await tmux(["pipe-pane", "-t", session])
|
||||||
|
const resized = await captureVisiblePane(session)
|
||||||
|
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
||||||
|
await Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized)
|
||||||
|
|
||||||
|
llm.queue(
|
||||||
|
llm.toolCall({
|
||||||
|
index: 0,
|
||||||
|
id: "mini-slow-shell",
|
||||||
|
name: "shell",
|
||||||
|
input: { command: "sleep 10" },
|
||||||
|
}),
|
||||||
|
llm.finish("tool-calls"),
|
||||||
|
)
|
||||||
|
await tmux(["send-keys", "-t", session, "-l", "interrupt this turn"])
|
||||||
|
await Bun.sleep(100)
|
||||||
|
await tmux(["send-keys", "-H", "-t", session, "0d"])
|
||||||
|
await waitForPane(session, "$ sleep 10")
|
||||||
|
await tmux(["send-keys", "-t", session, "Escape"])
|
||||||
|
const armed = await waitForPane(session, "again to interrupt")
|
||||||
|
await Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed)
|
||||||
|
await tmux(["send-keys", "-t", session, "Escape"])
|
||||||
|
const interrupted = await waitForPane(session, "Step interrupted", 10_000)
|
||||||
|
await Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted)
|
||||||
|
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
||||||
|
|
||||||
|
await tmux(["send-keys", "-t", session, "C-c"])
|
||||||
|
await waitForPane(session, "Press ctrl+c again to exit")
|
||||||
|
await tmux(["send-keys", "-t", session, "C-c"])
|
||||||
|
await waitForDeadPane(session)
|
||||||
|
const status = await paneDeadStatus(session)
|
||||||
|
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
|
||||||
|
const exited = await capturePane(session)
|
||||||
|
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
||||||
|
throw new Error("Mini exit splash was not rendered before teardown")
|
||||||
|
await Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited)
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener("abort", abort)
|
||||||
|
await tmux(["kill-session", "-t", session], true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @param {string[]} args */
|
||||||
|
async function tmux(args, allowFailure = false) {
|
||||||
|
const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||||
|
let timedOut = false
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
child.kill("SIGKILL")
|
||||||
|
}, 5_000)
|
||||||
|
const [status, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
|
||||||
|
if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
|
||||||
|
return stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} session */
|
||||||
|
function capturePane(session) {
|
||||||
|
return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} session */
|
||||||
|
function captureVisiblePane(session) {
|
||||||
|
return tmux(["capture-pane", "-p", "-t", session])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} session */
|
||||||
|
async function paneAlive(session) {
|
||||||
|
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} session */
|
||||||
|
async function paneDeadStatus(session) {
|
||||||
|
return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} session
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} [timeout]
|
||||||
|
* @param {(() => Promise<void>) | undefined} [trigger]
|
||||||
|
*/
|
||||||
|
async function waitForPane(session, text, timeout = 5_000, trigger) {
|
||||||
|
const deadline = Date.now() + timeout
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await trigger?.()
|
||||||
|
last = await capturePane(session)
|
||||||
|
if (last.includes(text)) return last
|
||||||
|
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} session */
|
||||||
|
async function waitForDeadPane(session) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (!(await paneAlive(session))) return
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("Mini did not tear down after the exit sequence")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} file
|
||||||
|
* @param {(value: string) => boolean} accept
|
||||||
|
*/
|
||||||
|
async function waitForFile(file, accept) {
|
||||||
|
let value = ""
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
value = await Bun.file(file)
|
||||||
|
.text()
|
||||||
|
.catch(() => "")
|
||||||
|
if (accept(value)) return value
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("resize did not replay committed transcript output")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function configureServicePort(artifacts) {
|
||||||
|
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||||
|
const port = probe.port
|
||||||
|
await probe.stop(true)
|
||||||
|
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||||
|
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||||
|
await mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, JSON.stringify({ port }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function serviceRegistration(artifacts) {
|
||||||
|
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||||
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
|
for (const name of ["service-local.json", "service.json"]) {
|
||||||
|
const value = await Bun.file(path.join(directory, name))
|
||||||
|
.json()
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (isRegistration(value)) return value
|
||||||
|
}
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("Drive service registration was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} value */
|
||||||
|
function isRegistration(value) {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"url" in value &&
|
||||||
|
typeof value.url === "string" &&
|
||||||
|
"password" in value &&
|
||||||
|
typeof value.password === "string"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineScript } from "opencode-drive"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
export default defineScript({
|
||||||
|
launch: "manual",
|
||||||
|
setup({ config }) {
|
||||||
|
config.autoupdate = false
|
||||||
|
},
|
||||||
|
async run({ artifacts, llm, server }) {
|
||||||
|
await configureServicePort(artifacts)
|
||||||
|
llm.queue(llm.text("drive noninteractive smoke ok"))
|
||||||
|
await server.launch()
|
||||||
|
|
||||||
|
const registration = await serviceRegistration(artifacts)
|
||||||
|
const root = path.resolve(import.meta.dir, "../../../..")
|
||||||
|
const directory = path.join(artifacts, "files")
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
path.join(root, "packages/cli/src/index.ts"),
|
||||||
|
"run",
|
||||||
|
"--server",
|
||||||
|
registration.url,
|
||||||
|
"drive smoke",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: path.join(root, "packages/cli"),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PWD: directory,
|
||||||
|
OPENCODE_PASSWORD: registration.password,
|
||||||
|
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||||
|
},
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
|
||||||
|
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function configureServicePort(artifacts) {
|
||||||
|
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||||
|
const port = probe.port
|
||||||
|
await probe.stop(true)
|
||||||
|
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||||
|
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||||
|
await mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, JSON.stringify({ port }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} artifacts */
|
||||||
|
async function serviceRegistration(artifacts) {
|
||||||
|
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||||
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
|
for (const name of ["service-local.json", "service.json"]) {
|
||||||
|
const value = await Bun.file(path.join(directory, name))
|
||||||
|
.json()
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (isRegistration(value)) return value
|
||||||
|
}
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("Drive service registration was not written")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {unknown} value */
|
||||||
|
function isRegistration(value) {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"url" in value &&
|
||||||
|
typeof value.url === "string" &&
|
||||||
|
"password" in value &&
|
||||||
|
typeof value.password === "string"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dir, "../../..")
|
||||||
|
|
||||||
|
describe("CLI frontend import boundaries", () => {
|
||||||
|
test("exposes only the intentional package entrypoints", async () => {
|
||||||
|
const run = await import("@opencode-ai/cli/run")
|
||||||
|
const mini = await import("@opencode-ai/tui/mini")
|
||||||
|
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
||||||
|
|
||||||
|
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||||
|
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||||
|
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps run and Mini on separate evaluation graphs", async () => {
|
||||||
|
const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
|
||||||
|
expect(run).toContain("packages/cli/src/run/run.ts")
|
||||||
|
expect(run).toContain("packages/tui/src/mini/tool.ts")
|
||||||
|
expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
|
||||||
|
expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
|
||||||
|
expect(run).not.toContain("packages/tui/src/mini/footer.ts")
|
||||||
|
expect(run).not.toContain("packages/tui/src/mini/scrollback.surface.ts")
|
||||||
|
expect(run).not.toContain("packages/tui/src/runtime.tsx")
|
||||||
|
|
||||||
|
const mini = await bundleInputs("packages/cli/src/commands/handlers/mini.ts")
|
||||||
|
expect(mini).toContain("packages/cli/src/mini.ts")
|
||||||
|
expect(mini).toContain("packages/tui/src/mini/index.ts")
|
||||||
|
expect(mini).toContain("packages/tui/src/mini/runtime.ts")
|
||||||
|
expect(mini).not.toContain("packages/cli/src/run/run.ts")
|
||||||
|
expect(mini).not.toContain("packages/cli/src/run/noninteractive.ts")
|
||||||
|
expect(mini).not.toContain("packages/cli/src/run/ui.ts")
|
||||||
|
expect(mini).not.toContain("packages/tui/src/runtime.tsx")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps TUI Mini independent from Core, Server, and CLI", async () => {
|
||||||
|
const glob = new Bun.Glob("**/*.{ts,tsx}")
|
||||||
|
const imports: string[] = []
|
||||||
|
for await (const file of glob.scan({ cwd: path.join(root, "packages/tui/src/mini") })) {
|
||||||
|
const source = await Bun.file(path.join(root, "packages/tui/src/mini", file)).text()
|
||||||
|
if (/["']@opencode-ai\/(?:core|server|cli)(?:\/[^"']*)?["']/.test(source)) imports.push(file)
|
||||||
|
}
|
||||||
|
expect(imports).toEqual([])
|
||||||
|
|
||||||
|
const graph = await bundleInputs("packages/tui/src/mini/index.ts")
|
||||||
|
expect(graph.filter((file) => file.startsWith("packages/core/"))).toEqual([])
|
||||||
|
expect(graph.filter((file) => file.startsWith("packages/cli/") || file.startsWith("packages/server/"))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function bundleInputs(entrypoint: string) {
|
||||||
|
const temporary = await mkdtemp(path.join(import.meta.dir, ".import-boundary-"))
|
||||||
|
const metafile = path.join(temporary, "meta.json")
|
||||||
|
try {
|
||||||
|
const child = Bun.spawn(
|
||||||
|
[
|
||||||
|
process.execPath,
|
||||||
|
"build",
|
||||||
|
entrypoint,
|
||||||
|
"--target=bun",
|
||||||
|
"--format=esm",
|
||||||
|
"--packages=bundle",
|
||||||
|
"--external=@opentui/core-*",
|
||||||
|
`--metafile=${metafile}`,
|
||||||
|
`--outdir=${path.join(temporary, "out")}`,
|
||||||
|
],
|
||||||
|
{ cwd: root, stdout: "pipe", stderr: "pipe" },
|
||||||
|
)
|
||||||
|
const [exitCode, stdout, stderr] = await Promise.all([
|
||||||
|
child.exited,
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
new Response(child.stderr).text(),
|
||||||
|
])
|
||||||
|
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||||
|
const metadata = await Bun.file(metafile).json()
|
||||||
|
return Object.keys(metadata.inputs).map((input) =>
|
||||||
|
path.relative(root, path.resolve(root, input)).replaceAll(path.sep, "/"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await rm(temporary, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { Readable } from "node:stream"
|
||||||
|
import { pathToFileURL } from "node:url"
|
||||||
|
import {
|
||||||
|
createMiniHost,
|
||||||
|
INTERACTIVE_INPUT_ERROR,
|
||||||
|
resolveInteractiveStdin,
|
||||||
|
type InteractiveStdin,
|
||||||
|
usingInteractiveStdin,
|
||||||
|
} from "../src/mini-host"
|
||||||
|
|
||||||
|
const temporary: string[] = []
|
||||||
|
const model = { providerID: "openai", modelID: "gpt-5" }
|
||||||
|
|
||||||
|
function stream(isTTY: boolean) {
|
||||||
|
return Object.assign(new Readable({ read() {} }), { isTTY }) as NodeJS.ReadStream
|
||||||
|
}
|
||||||
|
|
||||||
|
async function root() {
|
||||||
|
const directory = await mkdtemp(path.join(import.meta.dir, ".mini-host-"))
|
||||||
|
temporary.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
function host(terminal: InteractiveStdin, directory: string) {
|
||||||
|
return createMiniHost({
|
||||||
|
terminal,
|
||||||
|
directory,
|
||||||
|
paths: { home: directory, state: directory, log: directory },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporary.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Mini CLI host", () => {
|
||||||
|
test("reuses tty stdin without taking ownership", () => {
|
||||||
|
const stdin = stream(true)
|
||||||
|
const seen: string[] = []
|
||||||
|
const terminal = resolveInteractiveStdin(
|
||||||
|
stdin,
|
||||||
|
(target) => {
|
||||||
|
seen.push(target)
|
||||||
|
return stream(true)
|
||||||
|
},
|
||||||
|
"linux",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(terminal.stdin).toBe(stdin)
|
||||||
|
terminal.cleanup()
|
||||||
|
expect(stdin.destroyed).toBe(false)
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("opens and cleans the controlling terminal exactly once for piped stdin", () => {
|
||||||
|
const tty = stream(true)
|
||||||
|
const seen: string[] = []
|
||||||
|
let destroys = 0
|
||||||
|
const destroy = tty.destroy.bind(tty)
|
||||||
|
tty.destroy = ((...args: Parameters<typeof tty.destroy>) => {
|
||||||
|
destroys++
|
||||||
|
return destroy(...args)
|
||||||
|
}) as typeof tty.destroy
|
||||||
|
const terminal = resolveInteractiveStdin(
|
||||||
|
stream(false),
|
||||||
|
(target) => {
|
||||||
|
seen.push(target)
|
||||||
|
return tty
|
||||||
|
},
|
||||||
|
"linux",
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal.cleanup()
|
||||||
|
terminal.cleanup()
|
||||||
|
expect(seen).toEqual(["/dev/tty"])
|
||||||
|
expect(destroys).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the platform terminal and reports acquisition failures", () => {
|
||||||
|
const seen: string[] = []
|
||||||
|
resolveInteractiveStdin(
|
||||||
|
stream(false),
|
||||||
|
(target) => {
|
||||||
|
seen.push(target)
|
||||||
|
return stream(true)
|
||||||
|
},
|
||||||
|
"win32",
|
||||||
|
).cleanup()
|
||||||
|
expect(seen).toEqual(["CONIN$"])
|
||||||
|
expect(() =>
|
||||||
|
resolveInteractiveStdin(
|
||||||
|
stream(false),
|
||||||
|
() => {
|
||||||
|
throw new Error("open failed")
|
||||||
|
},
|
||||||
|
"linux",
|
||||||
|
),
|
||||||
|
).toThrow(INTERACTIVE_INPUT_ERROR)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleans the controlling terminal when hosted frontend startup fails", async () => {
|
||||||
|
let cleaned = 0
|
||||||
|
const order: string[] = []
|
||||||
|
const terminal = {
|
||||||
|
stdin: stream(true),
|
||||||
|
cleanup() {
|
||||||
|
order.push("cleanup")
|
||||||
|
cleaned++
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
usingInteractiveStdin(
|
||||||
|
async () => {
|
||||||
|
order.push("run")
|
||||||
|
throw new Error("frontend failed")
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
order.push("terminal")
|
||||||
|
return terminal
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toThrow("frontend failed")
|
||||||
|
expect(cleaned).toBe(1)
|
||||||
|
expect(order).toEqual(["terminal", "run", "cleanup"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribes and unsubscribes process signals through host capabilities", async () => {
|
||||||
|
const input = host({ stdin: stream(true), cleanup() {} }, await root())
|
||||||
|
const sigint = process.listenerCount("SIGINT")
|
||||||
|
const sigusr2 = process.listenerCount("SIGUSR2")
|
||||||
|
const offInt = input.signals.sigint.subscribe(() => {})
|
||||||
|
const offTheme = input.signals.sigusr2.subscribe(() => {})
|
||||||
|
|
||||||
|
expect(process.listenerCount("SIGINT")).toBe(sigint + 1)
|
||||||
|
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2 + 1)
|
||||||
|
offInt()
|
||||||
|
offInt()
|
||||||
|
offTheme()
|
||||||
|
offTheme()
|
||||||
|
expect(process.listenerCount("SIGINT")).toBe(sigint)
|
||||||
|
expect(process.listenerCount("SIGUSR2")).toBe(sigusr2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes paths, platform, timing, and diagnostic context", async () => {
|
||||||
|
const directory = await root()
|
||||||
|
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||||
|
|
||||||
|
expect(input.paths).toEqual({ home: directory, state: directory, log: directory })
|
||||||
|
expect(input.platform).toBe(process.platform)
|
||||||
|
expect(typeof input.files.readText).toBe("function")
|
||||||
|
const file = path.join(directory, "attachment.txt")
|
||||||
|
await Bun.write(file, "attachment contents")
|
||||||
|
expect(await input.files.readText(pathToFileURL(file).href)).toBe("attachment contents")
|
||||||
|
expect(typeof input.startup.showTiming).toBe("boolean")
|
||||||
|
expect(typeof input.startup.now()).toBe("number")
|
||||||
|
expect(input.diagnostics).toMatchObject({ pid: process.pid, cwd: directory })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("merges, clears, and repairs persisted model variants", async () => {
|
||||||
|
const directory = await root()
|
||||||
|
const input = host({ stdin: stream(true), cleanup() {} }, directory)
|
||||||
|
const file = path.join(directory, "model.json")
|
||||||
|
await Bun.write(
|
||||||
|
file,
|
||||||
|
JSON.stringify({
|
||||||
|
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||||
|
variant: { "openai/gpt-4.1": "low", invalid: 42 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await input.preferences.saveVariant(model, "high")
|
||||||
|
expect(await input.preferences.resolveVariant(model)).toBe("high")
|
||||||
|
expect(await Bun.file(file).json()).toEqual({
|
||||||
|
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||||
|
variant: { "openai/gpt-4.1": "low", "openai/gpt-5": "high" },
|
||||||
|
})
|
||||||
|
|
||||||
|
await input.preferences.saveVariant(model, undefined)
|
||||||
|
expect(await input.preferences.resolveVariant(model)).toBeUndefined()
|
||||||
|
expect(await Bun.file(file).json()).toEqual({
|
||||||
|
recent: [{ providerID: "anthropic", modelID: "sonnet" }],
|
||||||
|
variant: { "openai/gpt-4.1": "low" },
|
||||||
|
})
|
||||||
|
|
||||||
|
await Bun.write(file, "{")
|
||||||
|
await input.preferences.saveVariant(model, "high")
|
||||||
|
expect(await Bun.file(file).json()).toEqual({ variant: { "openai/gpt-5": "high" } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini"
|
import { mergeInput as mergeInteractiveInput } from "../src/mini"
|
||||||
import { toolInlineInfo, toolView } from "../src/mini/tool"
|
import { mergeInput as mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/run/run"
|
||||||
|
|
||||||
async function cli(args: string[]) {
|
async function cli(args: string[]) {
|
||||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||||
@@ -19,29 +19,12 @@ async function cli(args: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("mini command", () => {
|
describe("mini command", () => {
|
||||||
test("renders the renamed shell tool with the shell rule", () => {
|
|
||||||
const part = {
|
|
||||||
id: "part-shell",
|
|
||||||
sessionID: "session-shell",
|
|
||||||
messageID: "message-shell",
|
|
||||||
callID: "call-shell",
|
|
||||||
tool: "shell",
|
|
||||||
state: {
|
|
||||||
status: "pending" as const,
|
|
||||||
input: { command: "pwd" },
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
expect(toolView(part.tool)).toEqual({ output: true, final: false })
|
|
||||||
expect(toolInlineInfo(part)).toMatchObject({ icon: "$", title: "pwd", mode: "block" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses piped stdin as the initial prompt", () => {
|
test("uses piped stdin as the initial prompt", () => {
|
||||||
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
|
expect(mergeInteractiveInput("from stdin", undefined)).toBe("from stdin")
|
||||||
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
expect(mergeInteractiveInput("from stdin", "from flag")).toBe("from stdin\nfrom flag")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps run as mini's non-interactive input mode", () => {
|
test("merges non-interactive argument and stdin input", () => {
|
||||||
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
expect(mergeNonInteractiveInput("from args", "from stdin")).toBe("from args\nfrom stdin")
|
||||||
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
expect(mergeNonInteractiveInput(undefined, "from stdin")).toBe("from stdin")
|
||||||
})
|
})
|
||||||
@@ -110,14 +93,7 @@ describe("mini command", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await cli([
|
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||||
"run",
|
|
||||||
"--server",
|
|
||||||
server.url.toString(),
|
|
||||||
"--model",
|
|
||||||
"definitely/missing",
|
|
||||||
"hi",
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result.exitCode).toBe(1)
|
expect(result.exitCode).toBe(1)
|
||||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||||
|
|||||||
+128
-3
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
import { OpenCode, type EventSubscribeOutput } from "@opencode-ai/client/promise"
|
||||||
import { runNonInteractivePrompt } from "@opencode-ai/cli/mini/noninteractive"
|
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
type V2Event = EventSubscribeOutput
|
||||||
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||||
@@ -50,9 +50,57 @@ function settled(outcome: "success" | "interrupted" = "success"): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepStarted(): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_step_started",
|
||||||
|
created: 1,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "test", id: "test-model" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepFailed(message: string): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_step_failed",
|
||||||
|
created: 2,
|
||||||
|
type: "session.step.failed",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 2, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
assistantMessageID: "msg_assistant",
|
||||||
|
error: { type: "provider.transport", message },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionFailed(message: string): V2Event {
|
||||||
|
return {
|
||||||
|
id: "evt_execution_failed",
|
||||||
|
created: 3,
|
||||||
|
type: "session.execution.failed",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
error: { type: "provider.transport", message },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
|
||||||
// live events the prompt admission triggers, keyed by the generated message ID.
|
// live events the prompt admission triggers, keyed by the generated message ID.
|
||||||
async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: FormInfo[]; attached?: boolean }) {
|
async function run(input: {
|
||||||
|
turn: (inputID: string) => V2Event[]
|
||||||
|
pendingForms?: FormInfo[]
|
||||||
|
attached?: boolean
|
||||||
|
format?: "default" | "json"
|
||||||
|
compatibility?: "v1"
|
||||||
|
}) {
|
||||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||||
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
|
||||||
let wake: (() => void) | undefined
|
let wake: (() => void) | undefined
|
||||||
@@ -88,15 +136,38 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
|
|||||||
message: "hello",
|
message: "hello",
|
||||||
files: [],
|
files: [],
|
||||||
thinking: false,
|
thinking: false,
|
||||||
format: "default",
|
format: input.format ?? "default",
|
||||||
auto: false,
|
auto: false,
|
||||||
attached: input.attached ?? false,
|
attached: input.attached ?? false,
|
||||||
|
compatibility: input.compatibility,
|
||||||
renderTool: () => Promise.resolve(),
|
renderTool: () => Promise.resolve(),
|
||||||
renderToolError: () => Promise.resolve(),
|
renderToolError: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function capture(input: Parameters<typeof run>[0]) {
|
||||||
|
const stdout: string[] = []
|
||||||
|
const stderr: string[] = []
|
||||||
|
const exitCode = process.exitCode
|
||||||
|
const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||||
|
stdout.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||||
|
stderr.push(String(chunk))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await run(input)
|
||||||
|
return { stdout: stdout.join(""), stderr: stderr.join("") }
|
||||||
|
} finally {
|
||||||
|
process.exitCode = exitCode ?? 0
|
||||||
|
stdoutWrite.mockRestore()
|
||||||
|
stderrWrite.mockRestore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
@@ -125,4 +196,58 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" })
|
||||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [
|
||||||
|
prompted(messageID),
|
||||||
|
stepStarted(),
|
||||||
|
stepFailed("Provider request failed"),
|
||||||
|
executionFailed("Provider request failed"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
output.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line)),
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ type: "step_start", part: expect.objectContaining({ type: "step-start" }) }),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "error",
|
||||||
|
error: { type: "provider.transport", message: "Provider request failed" },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
expect(output.stderr).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("V1 default output flushes step_start before an unrelated execution failure", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
turn: (messageID) => [prompted(messageID), stepStarted(), executionFailed("Execution failed")],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output.stdout).toBe("")
|
||||||
|
expect(output.stderr).toContain("> build · test-model")
|
||||||
|
expect(output.stderr).toContain("Error: \u001b[0mExecution failed")
|
||||||
|
expect(output.stderr.indexOf("> build · test-model")).toBeLessThan(output.stderr.indexOf("Execution failed"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("V1 preserves terminal-finish failure suppression before content", async () => {
|
||||||
|
const output = await capture({
|
||||||
|
compatibility: "v1",
|
||||||
|
format: "json",
|
||||||
|
turn: (messageID) => [
|
||||||
|
prompted(messageID),
|
||||||
|
stepStarted(),
|
||||||
|
stepFailed("Provider stream ended without a terminal finish event"),
|
||||||
|
executionFailed("Provider stream ended without a terminal finish event"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output).toEqual({ stdout: "", stderr: "" })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
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"
|
||||||
@@ -17,6 +18,13 @@ import os from "node:os"
|
|||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { ServiceConfig } from "../src/services/service-config"
|
import { ServiceConfig } from "../src/services/service-config"
|
||||||
|
|
||||||
|
test("managed service ports are stable per installation channel", () => {
|
||||||
|
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||||
|
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
||||||
|
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
||||||
|
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||||
|
})
|
||||||
|
|
||||||
test("local channel stores service config with the local service filename", async () => {
|
test("local channel stores service config with the local service filename", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +98,80 @@ test("preview registration migration never moves stable discovery", async () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("managed service writes its registration once", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-once-")
|
||||||
|
try {
|
||||||
|
const before = await fs.stat(service.registration)
|
||||||
|
await Bun.sleep(6_000)
|
||||||
|
const after = await fs.stat(service.registration)
|
||||||
|
expect(after.ino).toBe(before.ino)
|
||||||
|
expect(after.mtimeMs).toBe(before.mtimeMs)
|
||||||
|
expect(await Bun.file(service.registration).json()).toEqual(service.info)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("deleting a managed service registration stops its owner", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-delete-")
|
||||||
|
try {
|
||||||
|
await fs.rm(service.registration)
|
||||||
|
expect(await waitForExit(service.owner)).toBe(true)
|
||||||
|
expect(await Bun.file(service.registration).exists()).toBe(false)
|
||||||
|
await expectPortAvailable(service.port)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("deleting a failed service registration stops its owner", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-failed-delete-", true)
|
||||||
|
try {
|
||||||
|
await waitForFailed(service.info)
|
||||||
|
await fs.rm(service.registration)
|
||||||
|
expect(await waitForExit(service.owner)).toBe(true)
|
||||||
|
await expectPortAvailable(service.port)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("corrupting a managed service registration stops its owner", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-corrupt-")
|
||||||
|
try {
|
||||||
|
await fs.writeFile(service.registration, "not-json")
|
||||||
|
expect(await waitForExit(service.owner)).toBe(true)
|
||||||
|
expect(await Bun.file(service.registration).text()).toBe("not-json")
|
||||||
|
await expectPortAvailable(service.port)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("replacing a managed service registration stops its owner and preserves the foreign owner", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-foreign-")
|
||||||
|
const foreign = { ...service.info, id: "foreign-owner", pid: process.pid }
|
||||||
|
try {
|
||||||
|
await fs.writeFile(service.registration, JSON.stringify(foreign))
|
||||||
|
expect(await waitForExit(service.owner)).toBe(true)
|
||||||
|
expect(await Bun.file(service.registration).json()).toEqual(foreign)
|
||||||
|
await expectPortAvailable(service.port)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("clean managed service shutdown removes its registration", async () => {
|
||||||
|
const service = await startManagedService("opencode-service-clean-")
|
||||||
|
try {
|
||||||
|
await Effect.runPromise(Service.stop({ file: service.registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
|
expect(await waitForExit(service.owner)).toBe(true)
|
||||||
|
expect(await Bun.file(service.registration).exists()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await stopManagedService(service)
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
test("concurrent service processes elect one server", async () => {
|
test("concurrent service processes elect one server", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
||||||
const database = path.join(root, "opencode.db")
|
const database = path.join(root, "opencode.db")
|
||||||
@@ -130,18 +212,34 @@ 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 processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
|
const port = await availablePort()
|
||||||
|
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
await fs.writeFile(config, JSON.stringify({ port }))
|
||||||
|
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "pipe" }))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await waitForInfo(registration)
|
const info = await waitForInfo(registration)
|
||||||
const winner = processes.find((process) => process.pid === info.pid)
|
const winner = processes.find((process) => process.pid === info.pid)
|
||||||
const losers = processes.filter((process) => process.pid !== info.pid)
|
const losers = processes.filter((process) => process.pid !== info.pid)
|
||||||
const exited = await Promise.all(
|
const exited = await Promise.all(
|
||||||
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
|
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(60_000).then(() => false)])),
|
||||||
)
|
)
|
||||||
|
|
||||||
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((await Bun.file(config).json()).password).toBe(info.password)
|
||||||
|
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), {
|
||||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||||
@@ -151,20 +249,6 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
version: info.version,
|
version: info.version,
|
||||||
pid: info.pid,
|
pid: info.pid,
|
||||||
})
|
})
|
||||||
const blockedTemp = registration + "." + info.id + ".tmp"
|
|
||||||
await fs.mkdir(blockedTemp)
|
|
||||||
await fs.rm(registration)
|
|
||||||
await Bun.sleep(6_000)
|
|
||||||
expect(await Bun.file(registration).exists()).toBe(false)
|
|
||||||
await fs.rm(blockedTemp, { recursive: true })
|
|
||||||
const restored = await waitForInfo(registration)
|
|
||||||
expect(restored.id).toBe(info.id)
|
|
||||||
expect(restored.pid).toBe(info.pid)
|
|
||||||
await fs.writeFile(registration, "not-json")
|
|
||||||
const repaired = await waitForInfo(registration)
|
|
||||||
expect(repaired.id).toBe(info.id)
|
|
||||||
expect(repaired.pid).toBe(info.pid)
|
|
||||||
|
|
||||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||||
try {
|
try {
|
||||||
const contenderExited = await Promise.race([
|
const contenderExited = await Promise.race([
|
||||||
@@ -172,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")
|
||||||
@@ -193,18 +278,200 @@ 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 {
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
expect(await Bun.file(registration).exists()).toBe(false)
|
|
||||||
} finally {
|
|
||||||
await fs.rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 60_000)
|
}, 120_000)
|
||||||
|
|
||||||
test("a failed service stays registered and owns the lock until stopped", async () => {
|
test("configured managed service port overrides the channel default", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-port-"))
|
||||||
|
const port = await availablePort()
|
||||||
|
const env = serviceEnv(root)
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
await fs.writeFile(config, JSON.stringify({ port, password: "" }))
|
||||||
|
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env,
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const info = await waitForInfo(registration)
|
||||||
|
expect(new URL(info.url).port).toBe(String(port))
|
||||||
|
expect(info.password).not.toBe("")
|
||||||
|
expect((await Bun.file(config).json()).password).toBe(info.password)
|
||||||
|
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
|
await owner.exited
|
||||||
|
} finally {
|
||||||
|
owner.kill("SIGTERM")
|
||||||
|
await owner.exited
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||||
|
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||||
|
const port = listener.port
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||||
|
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env: serviceEnv(root),
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
expect(await contender.exited).not.toBe(0)
|
||||||
|
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
|
||||||
|
expect(output).toContain(`Managed service port ${port} on 127.0.0.1 is already in use by another process`)
|
||||||
|
expect(output).toContain("opencode service set port <port>")
|
||||||
|
expect(await Bun.file(registration).exists()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
listener.stop(true)
|
||||||
|
contender.kill("SIGTERM")
|
||||||
|
await contender.exited
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||||
|
const recognizing = Promise.withResolvers<void>()
|
||||||
|
const requests = { count: 0 }
|
||||||
|
using listener = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
requests.count += 1
|
||||||
|
if (requests.count === 2) recognizing.resolve()
|
||||||
|
return new Promise<Response>(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, "config", "opencode", "service-local.json"),
|
||||||
|
JSON.stringify({ port: listener.port }),
|
||||||
|
)
|
||||||
|
const stale = {
|
||||||
|
id: "stale",
|
||||||
|
version: InstallationVersion,
|
||||||
|
url: "http://127.0.0.1:1",
|
||||||
|
pid: process.pid,
|
||||||
|
password: "stale",
|
||||||
|
}
|
||||||
|
await fs.writeFile(registration, JSON.stringify(stale))
|
||||||
|
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env: serviceEnv(root),
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||||
|
const exitCode = await Promise.race([contender.exited, Bun.sleep(20_000).then(() => undefined)])
|
||||||
|
expect(exitCode).toBe(1)
|
||||||
|
const output = (await new Response(contender.stdout).text()) + (await new Response(contender.stderr).text())
|
||||||
|
expect(output).toContain(`Managed service port ${listener.port} on 127.0.0.1 is already in use by another process`)
|
||||||
|
expect(await Bun.file(registration).json()).toEqual(stale)
|
||||||
|
} finally {
|
||||||
|
contender.kill("SIGTERM")
|
||||||
|
await contender.exited
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 45_000)
|
||||||
|
|
||||||
|
test("port contender recognizes an incumbent registered during the bind race", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-race-"))
|
||||||
|
const recognizing = Promise.withResolvers<void>()
|
||||||
|
const requests = { count: 0 }
|
||||||
|
using listener = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
requests.count += 1
|
||||||
|
if (requests.count === 2) recognizing.resolve()
|
||||||
|
return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
const config = path.join(root, "config", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.dirname(config), { recursive: true })
|
||||||
|
await fs.writeFile(config, JSON.stringify({ port: listener.port }))
|
||||||
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
registration,
|
||||||
|
JSON.stringify({
|
||||||
|
id: "stale",
|
||||||
|
version: InstallationVersion,
|
||||||
|
url: "http://127.0.0.1:1",
|
||||||
|
pid: 2_147_483_647,
|
||||||
|
password: "stale",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env: serviceEnv(root),
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||||
|
await Bun.sleep(8_000)
|
||||||
|
const info = {
|
||||||
|
id: "incumbent",
|
||||||
|
version: InstallationVersion,
|
||||||
|
url: `http://127.0.0.1:${listener.port}`,
|
||||||
|
pid: process.pid,
|
||||||
|
password: "incumbent",
|
||||||
|
}
|
||||||
|
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 () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
|
||||||
|
const port = await availablePort()
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||||
|
await fs.writeFile(
|
||||||
|
registration,
|
||||||
|
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
|
||||||
|
)
|
||||||
|
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env: serviceEnv(root),
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const info = await waitForInfo(registration, (value) => value.id !== "dead")
|
||||||
|
expect(new URL(info.url).port).toBe(String(port))
|
||||||
|
expect(info.pid).toBe(owner.pid)
|
||||||
|
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||||
|
await owner.exited
|
||||||
|
} finally {
|
||||||
|
owner.kill("SIGTERM")
|
||||||
|
await owner.exited
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
test("a failed service stays registered and owns the selected port until stopped", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
||||||
const database = path.join(root, "database")
|
const database = path.join(root, "database")
|
||||||
await fs.mkdir(database)
|
await fs.mkdir(database)
|
||||||
@@ -224,10 +491,12 @@ test("a failed service stays registered and owns the lock until stopped", async
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -275,13 +544,86 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForInfo(file: string) {
|
async function waitForInfo(file: string, accept: (info: Info) => boolean = () => true) {
|
||||||
for (let attempt = 0; attempt < 400; attempt++) {
|
for (let attempt = 0; attempt < 400; attempt++) {
|
||||||
const value = await Bun.file(file)
|
const value = await Bun.file(file)
|
||||||
.json()
|
.json()
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (value !== undefined) return Schema.decodeUnknownPromise(Service.Info)(value)
|
if (value !== undefined) {
|
||||||
|
const info = await Schema.decodeUnknownPromise(Service.Info)(value)
|
||||||
|
if (accept(info)) return info
|
||||||
|
}
|
||||||
await Bun.sleep(50)
|
await Bun.sleep(50)
|
||||||
}
|
}
|
||||||
throw new Error("Timed out waiting for service registration")
|
throw new Error("Timed out waiting for service registration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForFailed(info: Info) {
|
||||||
|
for (let attempt = 0; attempt < 400; attempt++) {
|
||||||
|
const status = await fetch(new URL("/api/health", info.url), {
|
||||||
|
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||||
|
})
|
||||||
|
.then((response) => response.status)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (status === 500) return
|
||||||
|
await Bun.sleep(50)
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for service boot failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function availablePort() {
|
||||||
|
const server = Bun.serve({ port: 0, fetch: () => new Response() })
|
||||||
|
const port = server.port
|
||||||
|
await server.stop(true)
|
||||||
|
if (port === undefined) throw new Error("Server did not bind a port")
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceEnv(root: string) {
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
HOME: root,
|
||||||
|
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||||
|
OPENCODE_TEST_HOME: root,
|
||||||
|
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||||
|
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||||
|
XDG_DATA_HOME: path.join(root, "data"),
|
||||||
|
XDG_STATE_HOME: path.join(root, "state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startManagedService(prefix: string, failBoot = false) {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||||
|
const port = await availablePort()
|
||||||
|
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||||
|
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||||
|
if (failBoot) await fs.mkdir(path.join(root, "database"))
|
||||||
|
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||||
|
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||||
|
env: failBoot ? { ...serviceEnv(root), OPENCODE_DB: path.join(root, "database") } : serviceEnv(root),
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
})
|
||||||
|
const info = await waitForInfo(registration).catch(async (cause) => {
|
||||||
|
owner.kill("SIGTERM")
|
||||||
|
await owner.exited
|
||||||
|
await fs.rm(root, { recursive: true, force: true })
|
||||||
|
throw cause
|
||||||
|
})
|
||||||
|
return { root, port, registration, owner, info }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopManagedService(service: Awaited<ReturnType<typeof startManagedService>>) {
|
||||||
|
service.owner.kill("SIGTERM")
|
||||||
|
await service.owner.exited
|
||||||
|
await fs.rm(service.root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(process: Bun.Subprocess, timeout = 10_000) {
|
||||||
|
return Promise.race([process.exited.then(() => true), Bun.sleep(timeout).then(() => false)])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPortAvailable(port: number) {
|
||||||
|
const server = Bun.serve({ hostname: "127.0.0.1", port, fetch: () => new Response() })
|
||||||
|
await server.stop(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ export const discover = Effect.fn("service.discover")(function* (options: Discov
|
|||||||
return (yield* discoverLocal(options))?.endpoint
|
return (yield* discoverLocal(options))?.endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
|
||||||
|
export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||||
|
options: DiscoverOptions & { readonly url: string },
|
||||||
|
) {
|
||||||
|
const info = yield* read(options.file)
|
||||||
|
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||||
|
if (found === undefined || found.legacy) return undefined
|
||||||
|
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||||
|
return { endpoint: found.endpoint, state: found.state }
|
||||||
|
})
|
||||||
|
|
||||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||||
const found = (yield* registered(options.file)).service
|
const found = (yield* registered(options.file)).service
|
||||||
if (found?.state !== "ready") return undefined
|
if (found?.state !== "ready") return undefined
|
||||||
@@ -101,8 +112,15 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
lastSpawn = Date.now()
|
lastSpawn = Date.now()
|
||||||
}
|
}
|
||||||
return Option.none<LocalService>()
|
return Option.none<LocalService>()
|
||||||
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }))
|
}).pipe(
|
||||||
return Option.getOrThrow(found).endpoint
|
Effect.repeat({
|
||||||
|
until: Option.isSome,
|
||||||
|
schedule: Schedule.max([Schedule.spaced("1 second"), Schedule.recurs(120)]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (Option.isNone(found))
|
||||||
|
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||||
|
return found.value.endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
function contenderFailure(contender: Contender) {
|
function contenderFailure(contender: Contender) {
|
||||||
@@ -273,4 +291,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** Effect-based local service lifecycle operations. */
|
/** Effect-based local service lifecycle operations. */
|
||||||
export const Service = { discover, ensure, stop, headers, Info }
|
export const Service = { discover, incumbent, ensure, stop, headers, Info }
|
||||||
|
|||||||
@@ -1276,6 +1276,8 @@ export type SessionStepFailed = {
|
|||||||
error: SessionStructuredError
|
error: SessionStructuredError
|
||||||
cost?: MoneyUSD
|
cost?: MoneyUSD
|
||||||
tokens?: TokenUsageInfo
|
tokens?: TokenUsageInfo
|
||||||
|
snapshot?: string
|
||||||
|
files?: Array<string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { spawn, type ChildProcess } from "node:child_process"
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type {
|
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||||
DiscoverOptions,
|
|
||||||
Endpoint,
|
|
||||||
Info,
|
|
||||||
EnsureOptions,
|
|
||||||
StopOptions,
|
|
||||||
} from "../service.js"
|
|
||||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||||
|
|
||||||
export * from "../service.js"
|
export * from "../service.js"
|
||||||
@@ -38,6 +32,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
|||||||
|
|
||||||
/** Ensure a healthy, compatible local service is running. */
|
/** Ensure a healthy, compatible local service is running. */
|
||||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||||
|
const deadline = Date.now() + 120_000
|
||||||
const contenders = new Set<Contender>()
|
const contenders = new Set<Contender>()
|
||||||
let announced = false
|
let announced = false
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
@@ -66,6 +61,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||||
const registration = await registered(options.file, true)
|
const registration = await registered(options.file, true)
|
||||||
|
|
||||||
if (registration.service !== undefined) {
|
if (registration.service !== undefined) {
|
||||||
@@ -128,7 +124,9 @@ function fallback() {
|
|||||||
/** Create HTTP authentication headers for a service endpoint. */
|
/** Create HTTP authentication headers for a service endpoint. */
|
||||||
export function headers(endpoint: Endpoint) {
|
export function headers(endpoint: Endpoint) {
|
||||||
if (endpoint.auth === undefined) return undefined
|
if (endpoint.auth === undefined) return undefined
|
||||||
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
|
return {
|
||||||
|
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function read(file?: string) {
|
async function read(file?: string) {
|
||||||
@@ -227,7 +225,8 @@ async function kill(service: LocalService, options: { readonly file?: string })
|
|||||||
const latest = await find(options)
|
const latest = await find(options)
|
||||||
if (latest === undefined || !same(latest.info, service.info)) return
|
if (latest === undefined || !same(latest.info, service.info)) return
|
||||||
signal(service.info.pid, "SIGKILL")
|
signal(service.info.pid, "SIGKILL")
|
||||||
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
|
if (!(await waitUntilStopped(service.info.pid)))
|
||||||
|
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestStop(service: LocalService) {
|
async function requestStop(service: LocalService) {
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
|||||||
|
|
||||||
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
||||||
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
||||||
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
|
model-visible; generated tools require `HttpClient.HttpClient` in the environment. `readOnly` properties are omitted
|
||||||
|
from request signatures and `writeOnly` properties from response signatures. These JSON Schemas are model-facing, not
|
||||||
|
runtime filters: nested value bodies and server responses pass through unchanged. See the option docstrings in
|
||||||
`src/openapi/types.ts` for full semantics.
|
`src/openapi/types.ts` for full semantics.
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|||||||
@@ -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,16 +1880,18 @@ 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])
|
||||||
}
|
}
|
||||||
return new GlobalMethodReference(objectValue.name, key)
|
if (globalStaticMembers[objectValue.name]?.has(key)) {
|
||||||
|
return new GlobalMethodReference(objectValue.name, key)
|
||||||
|
}
|
||||||
|
// Unknown static members read as undefined so feature detection works like native JS.
|
||||||
|
return new ComputedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof objectValue === "string") {
|
if (typeof objectValue === "string") {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ The initial adapter intentionally skips operations it cannot execute correctly.
|
|||||||
- Cookie parameters, authentication, and cookie-header merging.
|
- Cookie parameters, authentication, and cookie-header merging.
|
||||||
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
||||||
- External references and complete nested `$defs` support.
|
- External references and complete nested `$defs` support.
|
||||||
|
- `$anchor` and nested `$id` resource resolution in directional (`readOnly`/`writeOnly`) projection.
|
||||||
|
- Use-site cleanup for `allOf` branches that reference shared component schemas: per-direction component definitions are projected globally, so a directional annotation declared only at one use site cannot remove the property from a referenced component's definition.
|
||||||
|
- Hidden-name cleanup inside `then`/`else`/`dependentSchemas`/`dependentRequired`, which constrain the same instance as `allOf`; a hidden property may remain named in those keywords.
|
||||||
|
- Projection inside `not`/`if`/`contains`, whose semantics would invert or shift if constraints were removed; those subschemas pass through unchanged, and a `$ref` from such a context to a projected `$defs` or component definition still observes hiding.
|
||||||
|
- Iterative traversal for pathologically deep schema nesting: the directional scan and projection recurse per level and overflow the stack around ten thousand levels, below the pre-existing converter limit of roughly fifty thousand; `fromSpec` throws a catchable `RangeError` either way.
|
||||||
- Relative or templated server URLs and server variables.
|
- Relative or templated server URLs and server variables.
|
||||||
- Base URLs containing query strings or fragments.
|
- Base URLs containing query strings or fragments.
|
||||||
- Runtime response-schema validation and full content negotiation.
|
- Runtime response-schema validation and full content negotiation.
|
||||||
- Binary response values and explicit byte-oriented return types.
|
- Binary response values and explicit byte-oriented return types.
|
||||||
- Request/response projection for `readOnly` and `writeOnly` properties.
|
|
||||||
- SSE, WebSocket, and other streaming transports.
|
- SSE, WebSocket, and other streaming transports.
|
||||||
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
||||||
- Configurable request and response size limits.
|
- Configurable request and response size limits.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { make, type Definition } from "../tool.js"
|
|||||||
import { invoke } from "./runtime.js"
|
import { invoke } from "./runtime.js"
|
||||||
import {
|
import {
|
||||||
componentDefinitions,
|
componentDefinitions,
|
||||||
|
hasDirectionalSchemas,
|
||||||
inputSchema,
|
inputSchema,
|
||||||
isRecord,
|
isRecord,
|
||||||
methods,
|
methods,
|
||||||
@@ -38,7 +39,10 @@ export const fromSpec = (options: Options): Result => {
|
|||||||
const document = options.spec
|
const document = options.spec
|
||||||
const schemes = securitySchemes(document)
|
const schemes = securitySchemes(document)
|
||||||
const defaultSecurity = securityRequirements(document.security)
|
const defaultSecurity = securityRequirements(document.security)
|
||||||
const definitions = componentDefinitions(document)
|
const requestDefinitions = componentDefinitions(document, "request")
|
||||||
|
const responseDefinitions = hasDirectionalSchemas(document)
|
||||||
|
? componentDefinitions(document, "response")
|
||||||
|
: requestDefinitions
|
||||||
const paths = isRecord(document.paths) ? document.paths : {}
|
const paths = isRecord(document.paths) ? document.paths : {}
|
||||||
const used = new Set<string>()
|
const used = new Set<string>()
|
||||||
const namespaces = new Set<string>()
|
const namespaces = new Set<string>()
|
||||||
@@ -57,7 +61,7 @@ export const fromSpec = (options: Options): Result => {
|
|||||||
summary: nonEmptyString(operationValue.summary),
|
summary: nonEmptyString(operationValue.summary),
|
||||||
description: nonEmptyString(operationValue.description),
|
description: nonEmptyString(operationValue.description),
|
||||||
}
|
}
|
||||||
const output = operationOutput(document, operationValue, definitions)
|
const output = operationOutput(document, operationValue, responseDefinitions)
|
||||||
if (!output.ok) {
|
if (!output.ok) {
|
||||||
skipped.push({ method: operation.method, path, reason: output.reason })
|
skipped.push({ method: operation.method, path, reason: output.reason })
|
||||||
continue
|
continue
|
||||||
@@ -102,7 +106,7 @@ export const fromSpec = (options: Options): Result => {
|
|||||||
segments,
|
segments,
|
||||||
make({
|
make({
|
||||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||||
input: inputSchema(input.fields, definitions),
|
input: inputSchema(input.fields, requestDefinitions),
|
||||||
output: output.value,
|
output: output.value,
|
||||||
run: (input) => invoke(plan, input),
|
run: (input) => invoke(plan, input),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -27,22 +27,225 @@ export const nonEmptyString = (value: unknown): string | undefined =>
|
|||||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||||
Object.hasOwn(record, key) ? record[key] : undefined
|
Object.hasOwn(record, key) ? record[key] : undefined
|
||||||
|
|
||||||
|
const resolvePointer = (root: unknown, ref: string): unknown =>
|
||||||
|
ref
|
||||||
|
.slice(2)
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
|
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), root)
|
||||||
|
|
||||||
export const resolve = (document: Document, value: unknown): unknown => {
|
export const resolve = (document: Document, value: unknown): unknown => {
|
||||||
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
||||||
if (!isRecord(current)) return current
|
if (!isRecord(current)) return current
|
||||||
const ref = nonEmptyString(current.$ref)
|
const ref = nonEmptyString(own(current, "$ref"))
|
||||||
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
||||||
const target = ref
|
const target = resolvePointer(document, ref)
|
||||||
.slice(2)
|
|
||||||
.split("/")
|
|
||||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
||||||
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
|
|
||||||
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
||||||
}
|
}
|
||||||
return next(value, new Set())
|
return next(value, new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
// Model-facing directional projection: request schemas omit `readOnly` properties,
|
||||||
|
// response schemas omit `writeOnly` properties, and `required` stays consistent.
|
||||||
|
// Runtime values pass through unchanged.
|
||||||
|
type SchemaDirection = "request" | "response"
|
||||||
|
type SchemaResource = { readonly value: unknown; readonly root: unknown }
|
||||||
|
|
||||||
|
const hiddenKeyword = { request: "readOnly", response: "writeOnly" } as const
|
||||||
|
|
||||||
|
// Resolves one `$ref` hop so every link of a chain has its own sibling declarations
|
||||||
|
// inspected; cycles terminate in the callers' cycle solver. Local `$defs`/`definitions`
|
||||||
|
// pointers resolve against the schema being projected, other pointers rebase onto the target.
|
||||||
|
const resolveResource = (document: Document, resource: SchemaResource): SchemaResource => {
|
||||||
|
if (!isRecord(resource.value)) return resource
|
||||||
|
const ref = nonEmptyString(own(resource.value, "$ref"))
|
||||||
|
if (ref === undefined || !ref.startsWith("#/")) return resource
|
||||||
|
const local = ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")
|
||||||
|
const target = resolvePointer(local ? resource.root : document, ref)
|
||||||
|
if (target === undefined) return resource
|
||||||
|
return { value: target, root: local ? resource.root : target }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden-ness and hidden names are memoized per schema object and direction so
|
||||||
|
// diamond-shaped reference graphs stay linear. Documents are assumed immutable once
|
||||||
|
// projected; a schema reachable under multiple resolution roots reuses the first result.
|
||||||
|
type Solver<T> = {
|
||||||
|
readonly values: Map<unknown, T>
|
||||||
|
// Discovery index per schema whose strongly connected component is unresolved.
|
||||||
|
readonly pending: Map<unknown, number>
|
||||||
|
readonly stack: Array<unknown>
|
||||||
|
}
|
||||||
|
type DirectionCache = {
|
||||||
|
readonly hidden: Solver<boolean>
|
||||||
|
readonly names: Solver<ReadonlySet<string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCache = (): DirectionCache => ({
|
||||||
|
hidden: { values: new Map(), pending: new Map(), stack: [] },
|
||||||
|
names: { values: new Map(), pending: new Map(), stack: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectionCaches = new WeakMap<Document, Record<SchemaDirection, DirectionCache>>()
|
||||||
|
|
||||||
|
const projectionCache = (document: Document, direction: SchemaDirection): DirectionCache => {
|
||||||
|
const existing = projectionCaches.get(document)
|
||||||
|
if (existing !== undefined) return existing[direction]
|
||||||
|
const created = { request: emptyCache(), response: emptyCache() }
|
||||||
|
projectionCaches.set(document, created)
|
||||||
|
return created[direction]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tarjan's strongly connected components: cycle members all reach the same
|
||||||
|
// declarations, so the component root's value is final for every member. Only resolved
|
||||||
|
// components are cached, keeping results independent of traversal order.
|
||||||
|
type CycleScope = { lowlink: number }
|
||||||
|
|
||||||
|
const solveCycles = <T>(
|
||||||
|
solver: Solver<T>,
|
||||||
|
key: unknown,
|
||||||
|
provisional: T,
|
||||||
|
scope: CycleScope,
|
||||||
|
compute: (inner: CycleScope) => T,
|
||||||
|
): T => {
|
||||||
|
const cached = solver.values.get(key)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const pending = solver.pending.get(key)
|
||||||
|
if (pending !== undefined) {
|
||||||
|
scope.lowlink = Math.min(scope.lowlink, pending)
|
||||||
|
return provisional
|
||||||
|
}
|
||||||
|
// Components pop as contiguous stack suffixes, so pending indices stay 0..size-1.
|
||||||
|
const index = solver.pending.size
|
||||||
|
const base = solver.stack.length
|
||||||
|
solver.pending.set(key, index)
|
||||||
|
solver.stack.push(key)
|
||||||
|
const inner: CycleScope = { lowlink: Infinity }
|
||||||
|
const value = compute(inner)
|
||||||
|
if (inner.lowlink < index) {
|
||||||
|
scope.lowlink = Math.min(scope.lowlink, inner.lowlink)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for (const member of solver.stack.splice(base)) {
|
||||||
|
solver.pending.delete(member)
|
||||||
|
solver.values.set(member, value)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most documents have no directional keywords; one cached scan skips projection entirely.
|
||||||
|
const directionalDocuments = new WeakMap<Document, boolean>()
|
||||||
|
|
||||||
|
export const hasDirectionalSchemas = (document: Document): boolean => {
|
||||||
|
const cached = directionalDocuments.get(document)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const contains = (value: unknown): boolean => {
|
||||||
|
if (Array.isArray(value)) return value.some(contains)
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
if (own(value, "readOnly") === true || own(value, "writeOnly") === true) return true
|
||||||
|
return Object.values(value).some(contains)
|
||||||
|
}
|
||||||
|
const result = contains(document)
|
||||||
|
directionalDocuments.set(document, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAPI 3.1 allows keywords as siblings of `$ref`, so a schema's own declarations
|
||||||
|
// are inspected before following the reference.
|
||||||
|
const isHidden = (
|
||||||
|
document: Document,
|
||||||
|
resource: SchemaResource,
|
||||||
|
direction: SchemaDirection,
|
||||||
|
scope: CycleScope = { lowlink: Infinity },
|
||||||
|
): boolean => {
|
||||||
|
const value = resource.value
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
if (own(value, hiddenKeyword[direction]) === true) return true
|
||||||
|
return solveCycles(projectionCache(document, direction).hidden, value, false, scope, (inner) => {
|
||||||
|
const target = resolveResource(document, resource)
|
||||||
|
return (
|
||||||
|
asArray(own(value, "allOf")).some((item) => isHidden(document, { ...resource, value: item }, direction, inner)) ||
|
||||||
|
(target.value !== value && isHidden(document, target, direction, inner))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden property names declared by a schema itself or inherited through `$ref` and
|
||||||
|
// `allOf` composition, so sibling `required` lists stay consistent after projection.
|
||||||
|
const hiddenNames = (
|
||||||
|
document: Document,
|
||||||
|
resource: SchemaResource,
|
||||||
|
direction: SchemaDirection,
|
||||||
|
scope: CycleScope = { lowlink: Infinity },
|
||||||
|
): ReadonlySet<string> => {
|
||||||
|
const value = resource.value
|
||||||
|
if (!isRecord(value)) return new Set()
|
||||||
|
return solveCycles(projectionCache(document, direction).names, value, new Set(), scope, (inner) => {
|
||||||
|
const properties = own(value, "properties")
|
||||||
|
const declared = isRecord(properties)
|
||||||
|
? Object.entries(properties)
|
||||||
|
.filter(([, property]) => isHidden(document, { ...resource, value: property }, direction))
|
||||||
|
.map(([name]) => name)
|
||||||
|
: []
|
||||||
|
const composed = asArray(own(value, "allOf")).flatMap((item) => [
|
||||||
|
...hiddenNames(document, { ...resource, value: item }, direction, inner),
|
||||||
|
])
|
||||||
|
const target = resolveResource(document, resource)
|
||||||
|
const referenced = target.value === value ? [] : hiddenNames(document, target, direction, inner)
|
||||||
|
return new Set([...declared, ...composed, ...referenced])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// `not`/`if`/`contains` subschemas pass through unprojected: they negate or select
|
||||||
|
// rather than assert, so removing hidden properties would invert their semantics.
|
||||||
|
const nestedSchemas = new Set([
|
||||||
|
"items",
|
||||||
|
"additionalProperties",
|
||||||
|
"unevaluatedProperties",
|
||||||
|
"propertyNames",
|
||||||
|
"then",
|
||||||
|
"else",
|
||||||
|
])
|
||||||
|
const nestedSchemaLists = new Set(["anyOf", "oneOf", "prefixItems"])
|
||||||
|
const nestedSchemaMaps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"])
|
||||||
|
|
||||||
|
const directionalSchema = (
|
||||||
|
document: Document,
|
||||||
|
resource: SchemaResource,
|
||||||
|
direction: SchemaDirection,
|
||||||
|
excluded: ReadonlySet<string> = new Set(),
|
||||||
|
): unknown => {
|
||||||
|
if (!isRecord(resource.value)) return resource.value
|
||||||
|
const hidden = new Set([...excluded, ...hiddenNames(document, resource, direction)])
|
||||||
|
const project = (item: unknown, inherited: ReadonlySet<string> = new Set()): unknown =>
|
||||||
|
directionalSchema(document, { ...resource, value: item }, direction, inherited)
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(resource.value).map(([key, item]) => {
|
||||||
|
if (key === "properties" && isRecord(item)) {
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(item)
|
||||||
|
.filter(([name]) => !hidden.has(name))
|
||||||
|
.map(([name, property]) => [name, project(property)]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (key === "required" && Array.isArray(item)) {
|
||||||
|
return [key, item.filter((name) => typeof name !== "string" || !hidden.has(name))]
|
||||||
|
}
|
||||||
|
// allOf branches share one object; hidden names apply across every branch.
|
||||||
|
if (key === "allOf" && Array.isArray(item)) return [key, item.map((entry) => project(entry, hidden))]
|
||||||
|
if (nestedSchemas.has(key)) return [key, project(item)]
|
||||||
|
if (nestedSchemaLists.has(key) && Array.isArray(item)) return [key, item.map((entry) => project(entry))]
|
||||||
|
if (nestedSchemaMaps.has(key) && isRecord(item)) {
|
||||||
|
return [key, Object.fromEntries(Object.entries(item).map(([name, entry]) => [name, project(entry)]))]
|
||||||
|
}
|
||||||
|
return [key, item]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSchema = (document: Document, value: unknown): JsonSchema => {
|
||||||
if (!isRecord(value)) return {}
|
if (!isRecord(value)) return {}
|
||||||
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
||||||
? fromSchemaOpenApi3_0(value)
|
? fromSchemaOpenApi3_0(value)
|
||||||
@@ -52,10 +255,21 @@ const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
|||||||
: { ...normalized.schema, $defs: normalized.definitions }
|
: { ...normalized.schema, $defs: normalized.definitions }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
const projectSchema = (document: Document, value: unknown, direction: SchemaDirection): JsonSchema =>
|
||||||
|
normalizeSchema(
|
||||||
|
document,
|
||||||
|
hasDirectionalSchemas(document) ? directionalSchema(document, { value, root: value }, direction) : value,
|
||||||
|
)
|
||||||
|
|
||||||
|
export const componentDefinitions = (
|
||||||
|
document: Document,
|
||||||
|
direction: SchemaDirection,
|
||||||
|
): Readonly<Record<string, JsonSchema>> => {
|
||||||
const components = isRecord(document.components) ? document.components : {}
|
const components = isRecord(document.components) ? document.components : {}
|
||||||
const schemas = isRecord(components.schemas) ? components.schemas : {}
|
const schemas = isRecord(components.schemas) ? components.schemas : {}
|
||||||
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
|
return Object.fromEntries(
|
||||||
|
Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value, direction)]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
||||||
@@ -157,7 +371,7 @@ const operationParameters = (
|
|||||||
if (style === "deepObject" && !explode) {
|
if (style === "deepObject" && !explode) {
|
||||||
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
||||||
}
|
}
|
||||||
const base = projectSchema(document, resolved.schema)
|
const base = projectSchema(document, resolved.schema, "request")
|
||||||
const description = nonEmptyString(resolved.description)
|
const description = nonEmptyString(resolved.description)
|
||||||
unordered.push({
|
unordered.push({
|
||||||
name,
|
name,
|
||||||
@@ -191,7 +405,10 @@ const operationBody = (
|
|||||||
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const schema = resolve(document, selected.schema)
|
const resolvedSchema = resolve(document, selected.schema)
|
||||||
|
const schema = hasDirectionalSchemas(document)
|
||||||
|
? directionalSchema(document, { value: resolvedSchema, root: resolvedSchema }, "request")
|
||||||
|
: resolvedSchema
|
||||||
const required = resolved.required === true
|
const required = resolved.required === true
|
||||||
if (!isFlattenableObjectBody(schema, required)) {
|
if (!isFlattenableObjectBody(schema, required)) {
|
||||||
return {
|
return {
|
||||||
@@ -202,7 +419,7 @@ const operationBody = (
|
|||||||
name: "body",
|
name: "body",
|
||||||
location: "body",
|
location: "body",
|
||||||
required,
|
required,
|
||||||
schema: projectSchema(document, selected.schema),
|
schema: projectSchema(document, selected.schema, "request"),
|
||||||
style: undefined,
|
style: undefined,
|
||||||
explode: undefined,
|
explode: undefined,
|
||||||
},
|
},
|
||||||
@@ -217,11 +434,13 @@ const operationBody = (
|
|||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
value: {
|
value: {
|
||||||
|
// Field schemas were already projected with the body as resolution root; a second
|
||||||
|
// directional pass rooted at the field would misresolve shadowed local $defs.
|
||||||
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
||||||
name,
|
name,
|
||||||
location: "body" as const,
|
location: "body" as const,
|
||||||
required: required && requiredProperties.has(name),
|
required: required && requiredProperties.has(name),
|
||||||
schema: projectSchema(document, value),
|
schema: normalizeSchema(document, value),
|
||||||
style: undefined,
|
style: undefined,
|
||||||
explode: undefined,
|
explode: undefined,
|
||||||
})),
|
})),
|
||||||
@@ -339,7 +558,7 @@ export const operationOutput = (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
||||||
outcomes.push(projectSchema(document, value.schema))
|
outcomes.push(projectSchema(document, value.schema, "response"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (outcomes.length === 0) return { ok: true, value: undefined }
|
if (outcomes.length === 0) return { ok: true, value: undefined }
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
|
|||||||
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
|
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",
|
||||||
|
|||||||
@@ -59,6 +59,53 @@ const singleOperation = (operation: Record<string, unknown>, method = "get"): Do
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const directionalSpec = (openapi: string): Document => ({
|
||||||
|
openapi,
|
||||||
|
paths: {
|
||||||
|
"/users": {
|
||||||
|
post: {
|
||||||
|
operationId: "users.create",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Created",
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
ReadOnlyID: { type: "string", readOnly: true },
|
||||||
|
User: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["id", "name", "password", "profile", "generated"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", readOnly: true },
|
||||||
|
name: { type: "string" },
|
||||||
|
password: { type: "string", writeOnly: true },
|
||||||
|
profile: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["createdAt", "secret", "label"],
|
||||||
|
properties: {
|
||||||
|
createdAt: { type: "string", readOnly: true },
|
||||||
|
secret: { type: "string", writeOnly: true },
|
||||||
|
label: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generated: { $ref: "#/components/schemas/ReadOnlyID" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpenAPI.fromSpec", () => {
|
describe("OpenAPI.fromSpec", () => {
|
||||||
test("covers a representative API from generation through execution", async () => {
|
test("covers a representative API from generation through execution", async () => {
|
||||||
const resolutions: Array<string> = []
|
const resolutions: Array<string> = []
|
||||||
@@ -354,6 +401,550 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("projects read-only and write-only properties by schema direction", () => {
|
||||||
|
for (const version of ["3.0.3", "3.1.0"]) {
|
||||||
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
|
||||||
|
throw new Error(`users.create was not generated for OpenAPI ${version}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe(
|
||||||
|
"{ name: string; password: string; profile: { secret: string; label: string } }",
|
||||||
|
)
|
||||||
|
expect(outputTypeScript(tool)).toBe(
|
||||||
|
"{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }",
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
|
const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {}
|
||||||
|
const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {}
|
||||||
|
const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {}
|
||||||
|
expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([
|
||||||
|
"name",
|
||||||
|
"password",
|
||||||
|
"profile",
|
||||||
|
])
|
||||||
|
expect(requestUser.required).toEqual(["name", "password", "profile"])
|
||||||
|
expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"profile",
|
||||||
|
"generated",
|
||||||
|
])
|
||||||
|
expect(responseUser.required).toEqual(["id", "name", "profile", "generated"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects directional annotations through local refs and allOf composition", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["local", "composed", "name"],
|
||||||
|
properties: {
|
||||||
|
local: { $ref: "#/$defs/ReadOnlyValue" },
|
||||||
|
composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] },
|
||||||
|
name: { type: "string" },
|
||||||
|
},
|
||||||
|
$defs: {
|
||||||
|
ReadOnlyValue: { type: "string", readOnly: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("honors declarations that are siblings of a $ref", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
paths: {
|
||||||
|
"/test": {
|
||||||
|
post: {
|
||||||
|
operationId: "test",
|
||||||
|
responses: { 200: { description: "Success" } },
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["record"],
|
||||||
|
properties: {
|
||||||
|
record: {
|
||||||
|
$ref: "#/components/schemas/Base",
|
||||||
|
properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } },
|
||||||
|
required: ["extra", "note", "id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Base: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "name"],
|
||||||
|
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
|
const record = isRecord(properties.record) ? properties.record : {}
|
||||||
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
|
const base = isRecord(definitions.Base) ? definitions.Base : {}
|
||||||
|
|
||||||
|
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"])
|
||||||
|
expect(record.required).toEqual(["note"])
|
||||||
|
expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"])
|
||||||
|
expect(base.required).toEqual(["name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("honors directional declarations on intermediate reference hops", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: {
|
||||||
|
...singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["secret", "name"],
|
||||||
|
properties: {
|
||||||
|
// Hidden only by the sibling declaration on the middle hop.
|
||||||
|
secret: { $ref: "#/components/schemas/Middle" },
|
||||||
|
name: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Middle: { $ref: "#/components/schemas/Plain", readOnly: true },
|
||||||
|
Plain: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects cyclic component references without hanging", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
paths: {
|
||||||
|
"/test": {
|
||||||
|
post: {
|
||||||
|
operationId: "test",
|
||||||
|
responses: { 200: { description: "Success" } },
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Node: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "name", "child"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", readOnly: true },
|
||||||
|
name: { type: "string" },
|
||||||
|
child: { $ref: "#/components/schemas/Node" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
|
const node = isRecord(definitions.Node) ? definitions.Node : {}
|
||||||
|
|
||||||
|
expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"])
|
||||||
|
expect(node.required).toEqual(["name", "child"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects diamond-shaped reference graphs in linear time", () => {
|
||||||
|
// Each component references the next twice; without memoized hidden-ness this is 2^30 work.
|
||||||
|
const depth = 30
|
||||||
|
const schemas = Object.fromEntries(
|
||||||
|
Array.from({ length: depth }, (_, index) => [
|
||||||
|
`C${index}`,
|
||||||
|
index === depth - 1
|
||||||
|
? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } }
|
||||||
|
: { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
paths: {
|
||||||
|
"/test": {
|
||||||
|
post: {
|
||||||
|
operationId: "test",
|
||||||
|
responses: { 200: { description: "Success" } },
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: { schemas },
|
||||||
|
},
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
|
||||||
|
const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
|
||||||
|
|
||||||
|
expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves hiding through reference cycles regardless of evaluation order", () => {
|
||||||
|
// `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that
|
||||||
|
// enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`.
|
||||||
|
const schemas = {
|
||||||
|
Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] },
|
||||||
|
Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] },
|
||||||
|
}
|
||||||
|
const body = (properties: Record<string, unknown>) => ({
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: [...Object.keys(properties), "name"],
|
||||||
|
properties: { ...properties, name: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const properties of [
|
||||||
|
{ a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } },
|
||||||
|
{ a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } },
|
||||||
|
]) {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } },
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe("{ name: string }")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps not, if, and contains subschemas unprojected", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["record"],
|
||||||
|
properties: {
|
||||||
|
record: {
|
||||||
|
type: "object",
|
||||||
|
// Removing `secret` here would turn `not` unsatisfiable and
|
||||||
|
// flip which branch of `if` applies; both must pass through.
|
||||||
|
not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } },
|
||||||
|
if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
|
const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
|
||||||
|
|
||||||
|
expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } })
|
||||||
|
expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => {
|
||||||
|
// Deliberate scope bound: alternatives may apply, so a directional declaration on
|
||||||
|
// one alternative does not hide the property; the annotation is preserved as-is.
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["choice", "pick"],
|
||||||
|
properties: {
|
||||||
|
choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] },
|
||||||
|
pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
|
const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
|
||||||
|
const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
|
||||||
|
|
||||||
|
expect(Object.keys(properties)).toEqual(["choice", "pick"])
|
||||||
|
expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
|
||||||
|
expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not misresolve shadowed local $defs when flattening body fields", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["record"],
|
||||||
|
$defs: { Value: { type: "string" } },
|
||||||
|
properties: {
|
||||||
|
record: {
|
||||||
|
type: "object",
|
||||||
|
required: ["x"],
|
||||||
|
properties: { x: { $ref: "#/$defs/Value" } },
|
||||||
|
// Shadows the body-level Value; must not affect the body-rooted projection.
|
||||||
|
$defs: { Value: { type: "string", readOnly: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
|
const record = isRecord(properties.record) ? properties.record : {}
|
||||||
|
|
||||||
|
expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"])
|
||||||
|
expect(record.required).toEqual(["x"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("projects directional annotations inside parameter schemas", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation({
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "filter",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["state", "id"],
|
||||||
|
properties: { state: { type: "string" }, id: { type: "string", readOnly: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ignores inherited directional annotations", () => {
|
||||||
|
const inherited: Record<string, unknown> = { type: "string" }
|
||||||
|
Object.setPrototypeOf(inherited, { readOnly: true })
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation({
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "filter",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
// The own annotation on `id` keeps projection active for the document,
|
||||||
|
// so `value` pins that prototype-inherited annotations are not read.
|
||||||
|
properties: { value: inherited, id: { type: "string", readOnly: true } },
|
||||||
|
required: ["value", "id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
|
||||||
|
|
||||||
|
expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleans required properties across allOf branches", () => {
|
||||||
|
const tool = toolAt(
|
||||||
|
OpenAPI.fromSpec({
|
||||||
|
baseUrl,
|
||||||
|
spec: singleOperation(
|
||||||
|
{
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "name"],
|
||||||
|
allOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
required: ["id", "name"],
|
||||||
|
properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"post",
|
||||||
|
),
|
||||||
|
}).tools,
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
|
||||||
|
const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
|
||||||
|
const body = isRecord(properties.body) ? properties.body : {}
|
||||||
|
const allOf = Array.isArray(body.allOf) ? body.allOf : []
|
||||||
|
const branch = isRecord(allOf[0]) ? allOf[0] : {}
|
||||||
|
|
||||||
|
expect(body.required).toEqual(["name"])
|
||||||
|
expect(branch.required).toEqual(["name"])
|
||||||
|
expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps directional schemas model-facing while preserving runtime pass-through", async () => {
|
||||||
|
const client = recordingClient(() =>
|
||||||
|
json({
|
||||||
|
id: "server-id",
|
||||||
|
name: "Ada",
|
||||||
|
password: "returned-by-server",
|
||||||
|
profile: { createdAt: "today", secret: "returned-secret", label: "primary" },
|
||||||
|
generated: "generated-id",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
|
||||||
|
if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
|
||||||
|
|
||||||
|
const result = await Effect.runPromise(
|
||||||
|
tool
|
||||||
|
.run({
|
||||||
|
id: "ignored-top-level",
|
||||||
|
generated: "ignored-generated",
|
||||||
|
name: "Ada",
|
||||||
|
password: "request-secret",
|
||||||
|
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
|
||||||
|
})
|
||||||
|
.pipe(Effect.provide(client.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(client.requests[0]?.body).toEqual({
|
||||||
|
name: "Ada",
|
||||||
|
password: "request-secret",
|
||||||
|
profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
|
||||||
|
})
|
||||||
|
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
|
||||||
|
})
|
||||||
|
|
||||||
test("documents that the opencode fixture is unauthenticated", async () => {
|
test("documents that the opencode fixture is unauthenticated", async () => {
|
||||||
const spec = await opencodeSpec()
|
const spec = await opencodeSpec()
|
||||||
const components = isRecord(spec.components) ? spec.components : {}
|
const components = isRecord(spec.components) ? spec.components : {}
|
||||||
@@ -525,9 +1116,9 @@ describe("OpenAPI.fromSpec", () => {
|
|||||||
expect(client.requests[0]?.url).toBe(
|
expect(client.requests[0]?.url).toBe(
|
||||||
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
`${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
|
||||||
Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer))),
|
"Parameter 'tags' contains an unsupported nested value.",
|
||||||
).rejects.toThrow("Parameter 'tags' contains an unsupported nested value.")
|
)
|
||||||
await expect(
|
await expect(
|
||||||
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
|
||||||
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+24
-17
@@ -30,6 +30,7 @@ import {
|
|||||||
type UsageInput,
|
type UsageInput,
|
||||||
} from "@opencode-ai/ai"
|
} from "@opencode-ai/ai"
|
||||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||||
|
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||||
import { ModelV2 } from "./model"
|
import { ModelV2 } from "./model"
|
||||||
import { ProviderV2 } from "./provider"
|
import { ProviderV2 } from "./provider"
|
||||||
@@ -605,6 +606,7 @@ function streamPartEvents(
|
|||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
name: event.toolName,
|
name: event.toolName,
|
||||||
|
providerExecuted: event.providerExecuted,
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -622,15 +624,28 @@ function streamPartEvents(
|
|||||||
])
|
])
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
state.toolNames[event.toolCallId] = event.toolName
|
state.toolNames[event.toolCallId] = event.toolName
|
||||||
return Effect.succeed([
|
return ProviderShared.parseToolInput("aisdk", event.toolName, event.input).pipe(
|
||||||
LLMEvent.toolCall({
|
Effect.map((input) => [
|
||||||
id: event.toolCallId,
|
LLMEvent.toolCall({
|
||||||
name: event.toolName,
|
id: event.toolCallId,
|
||||||
input: parseToolInput(event.input),
|
name: event.toolName,
|
||||||
providerExecuted: event.providerExecuted,
|
input,
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerExecuted: event.providerExecuted,
|
||||||
}),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
])
|
}),
|
||||||
|
]),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
event.providerExecuted
|
||||||
|
? Effect.fail(error)
|
||||||
|
: Effect.succeed([
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name: event.toolName,
|
||||||
|
raw: event.input,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
case "tool-result":
|
case "tool-result":
|
||||||
delete state.toolNames[event.toolCallId]
|
delete state.toolNames[event.toolCallId]
|
||||||
return Effect.succeed([
|
return Effect.succeed([
|
||||||
@@ -685,14 +700,6 @@ function providerMetadata(value: unknown) {
|
|||||||
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolInput(value: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as unknown
|
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonObject(input: Record<string, unknown>) {
|
function jsonObject(input: Record<string, unknown>) {
|
||||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)]))
|
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonValue(value)]))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
|
|||||||
}),
|
}),
|
||||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
|
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||||
|
}),
|
||||||
timeout: Timeout.pipe(Schema.optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -40,6 +43,9 @@ export class Remote extends Schema.Class<Remote>("ConfigV2.MCP.Remote")({
|
|||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
oauth: Schema.Union([OAuth, Schema.Literal(false)]).pipe(Schema.optional),
|
||||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
|
||||||
|
description: "Expose this server's tools through Code Mode. Defaults to true.",
|
||||||
|
}),
|
||||||
timeout: Timeout.pipe(Schema.optional),
|
timeout: Timeout.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type Inputs = Integration.Inputs
|
|||||||
export type OAuthAuthorization = {
|
export type OAuthAuthorization = {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly instructions: string
|
readonly instructions: string
|
||||||
|
readonly expiresAt?: number
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
readonly mode: "auto"
|
readonly mode: "auto"
|
||||||
@@ -560,7 +561,7 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
const id = AttemptID.create()
|
const id = AttemptID.create()
|
||||||
const created = yield* Clock.currentTimeMillis
|
const created = yield* Clock.currentTimeMillis
|
||||||
const time = { created, expires: created + attemptLifetime }
|
const time = { created, expires: authorization.expiresAt ?? created + attemptLifetime }
|
||||||
yield* SynchronizedRef.update(attempts, (current) =>
|
yield* SynchronizedRef.update(attempts, (current) =>
|
||||||
new Map(current).set(id, {
|
new Map(current).set(id, {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
|
|||||||
export type Kind = typeof Kind.Type
|
export type Kind = typeof Kind.Type
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation paths do not accept project references. Relative paths must stay
|
* Mutation paths do not accept project references. Relative paths resolve
|
||||||
* inside the active Location. Absolute paths outside it require separate
|
* from the active Location. Paths outside it require separate
|
||||||
* `external_directory` approval.
|
* `external_directory` approval.
|
||||||
*/
|
*/
|
||||||
export const ResolveInput = Schema.Struct({
|
export const ResolveInput = Schema.Struct({
|
||||||
@@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
|
|||||||
|
|
||||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]),
|
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export interface ExternalDirectoryAuthorization {
|
export interface ExternalDirectoryAuthorization {
|
||||||
@@ -53,9 +53,9 @@ export interface Target {
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/**
|
/**
|
||||||
* Resolve a path and derive its permission resources. Relative paths must
|
* Resolve a path and derive its permission resources. Relative paths resolve
|
||||||
* stay inside the Location. Absolute paths outside it require separate
|
* from the Location. Paths outside it require separate `external_directory`
|
||||||
* `external_directory` approval. This does not approve the mutation.
|
* approval. This does not approve the mutation.
|
||||||
*/
|
*/
|
||||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, PathError | FSUtil.Error>
|
||||||
}
|
}
|
||||||
@@ -120,10 +120,8 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||||
const relative = !path.isAbsolute(input.path)
|
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||||
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
|
|
||||||
|
|
||||||
const resolved = yield* resolvePath(absolute)
|
const resolved = yield* resolvePath(absolute)
|
||||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
||||||
@@ -146,10 +144,7 @@ const layer = Layer.effect(
|
|||||||
directory: externalDirectory,
|
directory: externalDirectory,
|
||||||
resource: externalResource,
|
resource: externalResource,
|
||||||
save: slash(
|
save: slash(
|
||||||
path.join(
|
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
|
||||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
|
||||||
"*",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
+167
-73
@@ -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"
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.Se
|
|||||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||||
server: ServerName,
|
server: ServerName,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
codemode: Schema.Boolean.pipe(Schema.optional),
|
||||||
description: Schema.String.pipe(Schema.optional),
|
description: Schema.String.pipe(Schema.optional),
|
||||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||||
outputSchema: Schema.Unknown.pipe(Schema.optional),
|
outputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||||
@@ -118,6 +121,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 +131,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 +178,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 +194,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 +206,29 @@ 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,
|
// Each registration gets its own child scope so disposal detaches it from the root scope
|
||||||
methodID: Integration.MethodID.make(suffix),
|
// entirely; registering directly on root would accumulate a dead finalizer per replaced or
|
||||||
})
|
// removed server for the lifetime of the layer.
|
||||||
}
|
const scope = yield* Scope.fork(root)
|
||||||
if (registrations.length > 0)
|
entry.registration = { dispose: Scope.close(scope, Exit.void) }
|
||||||
yield* integration.transform((draft) => {
|
yield* integration
|
||||||
for (const reg of registrations) {
|
.transform((draft) => {
|
||||||
draft.update(reg.integrationID, (ref) => {
|
draft.update(integrationID, (ref) => {
|
||||||
ref.name = reg.name
|
ref.name = 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(scope))
|
||||||
|
})
|
||||||
|
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)
|
||||||
@@ -360,10 +368,11 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
} satisfies MCPClient.ElicitationHandler
|
} satisfies MCPClient.ElicitationHandler
|
||||||
|
|
||||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
|
||||||
new Tool({
|
new Tool({
|
||||||
server,
|
server,
|
||||||
name: def.name,
|
name: def.name,
|
||||||
|
codemode: entry.config.codemode,
|
||||||
description: def.description,
|
description: def.description,
|
||||||
inputSchema: def.inputSchema,
|
inputSchema: def.inputSchema,
|
||||||
outputSchema: def.outputSchema,
|
outputSchema: def.outputSchema,
|
||||||
@@ -405,7 +414,7 @@ export const layer = Layer.effect(
|
|||||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||||
connection.tools().pipe(
|
connection.tools().pipe(
|
||||||
Effect.map((defs) => {
|
Effect.map((defs) => {
|
||||||
entry.tools = defs.map((def) => toTool(name, def))
|
entry.tools = defs.map((def) => toTool(name, entry, def))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -420,36 +429,44 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||||
connection.onClose(() => {
|
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
|
||||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
const whenLive =
|
||||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||||
if (entry.client !== connection) return
|
<E>(effect: Effect.Effect<void, E>) =>
|
||||||
entry.client = undefined
|
|
||||||
entry.tools = undefined
|
|
||||||
entry.prompts = undefined
|
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
})
|
|
||||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
|
||||||
connection.onToolsChanged(() => {
|
|
||||||
fork(
|
fork(
|
||||||
refreshTools(name, entry, connection).pipe(
|
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
locks.withLock(name),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
connection.onPromptsChanged(() => {
|
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
const live = whenLive(name, entry, connection)
|
||||||
})
|
connection.onClose(() =>
|
||||||
connection.onResourcesChanged(() => {
|
live(
|
||||||
if (entry.client !== connection) return
|
Effect.gen(function* () {
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
entry.client = undefined
|
||||||
})
|
entry.tools = undefined
|
||||||
|
entry.prompts = undefined
|
||||||
|
entry.status = { status: "failed", error: "Connection closed" }
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||||
|
connection.onToolsChanged(() =>
|
||||||
|
live(
|
||||||
|
refreshTools(name, entry, connection).pipe(
|
||||||
|
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
|
||||||
|
connection.onResourcesChanged(() => live(events.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||||
@@ -472,6 +489,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)
|
||||||
@@ -484,7 +505,7 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
if (Exit.isSuccess(result)) {
|
if (Exit.isSuccess(result)) {
|
||||||
entry.client = result.value.connection
|
entry.client = result.value.connection
|
||||||
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
entry.tools = result.value.tools.map((def) => toTool(name, entry, def))
|
||||||
entry.prompts = []
|
entry.prompts = []
|
||||||
entry.status = { status: "connected" }
|
entry.status = { status: "connected" }
|
||||||
watch(name, entry, result.value.connection)
|
watch(name, entry, result.value.connection)
|
||||||
@@ -495,7 +516,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 +530,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 +550,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* startServer(name, entry)
|
||||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
}).pipe(locks.withLock(name))
|
||||||
}
|
|
||||||
yield* startServer(name, entry)
|
|
||||||
})
|
})
|
||||||
fork(
|
fork(
|
||||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
@@ -546,10 +577,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.
|
||||||
concurrency: "unbounded",
|
const whenAllReady = Effect.suspend(() =>
|
||||||
discard: true,
|
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
|
||||||
})
|
concurrency: "unbounded",
|
||||||
|
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 +596,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())
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Summary = typeof Summary.Type
|
|||||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||||
servers.flatMap((server) => [
|
servers.flatMap((server) => [
|
||||||
` <server name="${server.server}">`,
|
` <server name="${server.server}">`,
|
||||||
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.group(server.server))}]\`.`,
|
` Use tools from this server through \`execute\` under \`tools[${JSON.stringify(McpTool.namespace(server.server))}]\`.`,
|
||||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||||
" </server>",
|
" </server>",
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -398,14 +398,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
input: event.input,
|
input: event.input,
|
||||||
result: event.result,
|
result: event.result,
|
||||||
output: event.output,
|
output: event.output,
|
||||||
outputPaths: event.outputPaths,
|
|
||||||
}
|
}
|
||||||
return Reflect.apply(callback, undefined, [output]).pipe(
|
return Reflect.apply(callback, undefined, [output]).pipe(
|
||||||
Effect.tap(() =>
|
Effect.tap(() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
event.result = output.result
|
event.result = output.result
|
||||||
event.output = output.output
|
event.output = output.output
|
||||||
event.outputPaths = output.outputPaths
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,12 +133,20 @@ const device = {
|
|||||||
},
|
},
|
||||||
Device,
|
Device,
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.map((value) => ({
|
Effect.flatMap((value) =>
|
||||||
mode: "auto" as const,
|
Clock.currentTimeMillis.pipe(
|
||||||
url: value.verification_uri_complete ?? value.verification_uri,
|
Effect.map((created) => {
|
||||||
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
const lifetime = positiveSeconds(value.expires_in, 0)
|
||||||
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
return {
|
||||||
})),
|
mode: "auto" as const,
|
||||||
|
url: value.verification_uri_complete ?? value.verification_uri,
|
||||||
|
instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
|
||||||
|
...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
|
||||||
|
callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
|
||||||
} satisfies IntegrationOAuthMethodRegistration
|
} satisfies IntegrationOAuthMethodRegistration
|
||||||
|
|||||||
@@ -142,7 +142,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugin = yield* load(operation).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
const plugin = yield* load(operation).pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
if (!plugin) continue
|
if (!plugin) continue
|
||||||
const previous = packages.get(operation.target)
|
const previous = packages.get(operation.target)
|
||||||
if (previous) enabled.delete(previous.id)
|
if (previous) enabled.delete(previous.id)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -297,6 +297,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
draft.cost = event.data.cost
|
draft.cost = event.data.cost
|
||||||
draft.tokens = castDraft(event.data.tokens)
|
draft.tokens = castDraft(event.data.tokens)
|
||||||
}
|
}
|
||||||
|
if (event.data.snapshot || event.data.files)
|
||||||
|
draft.snapshot = {
|
||||||
|
...draft.snapshot,
|
||||||
|
end: event.data.snapshot,
|
||||||
|
files: event.data.files ? Array.from(event.data.files) : undefined,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.started": (event) => {
|
"session.text.started": (event) => {
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield* publish(event)
|
yield* publish(event)
|
||||||
|
if (LLMEvent.is.toolInputError(event)) {
|
||||||
|
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
|
||||||
|
return
|
||||||
|
}
|
||||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||||
const tool = prepared.resolveToolCall(event.name)
|
const tool = prepared.resolveToolCall(event.name)
|
||||||
if (tool.type === "reject") {
|
if (tool.type === "reject") {
|
||||||
@@ -195,25 +199,27 @@ const layer = Layer.effect(
|
|||||||
tokens: settlement.tokens,
|
tokens: settlement.tokens,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Captures the end snapshot, diffs it against the step's start, and durably ends the
|
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||||
// assistant step.
|
const snapshot = yield* snapshots.capture()
|
||||||
|
const files =
|
||||||
|
startSnapshot && snapshot
|
||||||
|
? yield* snapshots
|
||||||
|
.files({ from: startSnapshot, to: snapshot })
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
: undefined
|
||||||
|
return { snapshot, files }
|
||||||
|
})
|
||||||
|
|
||||||
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const endSnapshot = yield* snapshots.capture()
|
const end = yield* captureStepEnd()
|
||||||
const files =
|
|
||||||
startSnapshot && endSnapshot
|
|
||||||
? yield* snapshots
|
|
||||||
.files({ from: startSnapshot, to: endSnapshot })
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
: undefined
|
|
||||||
yield* serialized(
|
yield* serialized(
|
||||||
events.publish(SessionEvent.Step.Ended, {
|
events.publish(SessionEvent.Step.Ended, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
assistantMessageID: yield* publisher.startAssistant(),
|
assistantMessageID: yield* publisher.startAssistant(),
|
||||||
finish: settlement.finish,
|
finish: settlement.finish,
|
||||||
...stepUsage(settlement),
|
...stepUsage(settlement),
|
||||||
snapshot: endSnapshot,
|
...end,
|
||||||
files,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -324,8 +330,15 @@ const layer = Layer.effect(
|
|||||||
const stepFailure = publisher.stepFailure()
|
const stepFailure = publisher.stepFailure()
|
||||||
const stepSettlement = publisher.stepSettlement()
|
const stepSettlement = publisher.stepSettlement()
|
||||||
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
if (stepSettlement && !stepFailure) yield* publishStepEnd(stepSettlement)
|
||||||
if (stepFailure)
|
if (stepFailure) {
|
||||||
yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined))
|
const end = yield* captureStepEnd()
|
||||||
|
yield* serialized(
|
||||||
|
publisher.publishStepFailure({
|
||||||
|
...(stepSettlement ? stepUsage(stepSettlement) : {}),
|
||||||
|
...end,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||||
if (userDeclined) return yield* Effect.interrupt
|
if (userDeclined) return yield* Effect.interrupt
|
||||||
@@ -378,7 +391,11 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
.pipe(Effect.andThen(Effect.fail(error.cause)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
if (attempt._tag === "Completed")
|
||||||
|
return {
|
||||||
|
needsContinuation: attempt.needsContinuation,
|
||||||
|
step: attempt.step,
|
||||||
|
}
|
||||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
currentPromotion = undefined
|
currentPromotion = undefined
|
||||||
@@ -410,7 +427,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)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SessionError } from "@opencode-ai/schema/session-error"
|
|||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Snapshot } from "../../snapshot"
|
import { Snapshot } from "../../snapshot"
|
||||||
|
import { RelativePath } from "../../schema"
|
||||||
import { SessionUsage } from "../usage"
|
import { SessionUsage } from "../usage"
|
||||||
|
|
||||||
type Input = {
|
type Input = {
|
||||||
@@ -112,12 +113,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
if (state !== undefined) current.state = { ...current.state, ...state }
|
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||||
return Effect.succeed(current.ordinal)
|
return Effect.succeed(current.ordinal)
|
||||||
})
|
})
|
||||||
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
|
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
|
||||||
const current = chunks.get(id)
|
const current = chunks.get(id)
|
||||||
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
|
||||||
yield* ended(
|
yield* ended(
|
||||||
id,
|
id,
|
||||||
current.values.join(""),
|
value ?? current.values.join(""),
|
||||||
current.ordinal,
|
current.ordinal,
|
||||||
state === undefined ? current.state : { ...current.state, ...state },
|
state === undefined ? current.state : { ...current.state, ...state },
|
||||||
)
|
)
|
||||||
@@ -175,7 +176,11 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
yield* toolInput.flush()
|
yield* toolInput.flush()
|
||||||
})
|
})
|
||||||
|
|
||||||
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
const startToolInput = Effect.fnUntraced(function* (event: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly providerExecuted?: boolean
|
||||||
|
}) {
|
||||||
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
if (tools.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input start: ${event.id}`))
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
tools.set(event.id, {
|
tools.set(event.id, {
|
||||||
@@ -183,7 +188,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
called: false,
|
called: false,
|
||||||
settled: false,
|
settled: false,
|
||||||
providerExecuted: false,
|
providerExecuted: event.providerExecuted === true,
|
||||||
})
|
})
|
||||||
yield* toolInput.start(event.id)
|
yield* toolInput.start(event.id)
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
@@ -194,13 +199,41 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const endToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
|
const endToolInput = Effect.fnUntraced(function* (
|
||||||
|
event: { readonly id: string; readonly name: string },
|
||||||
|
value?: string,
|
||||||
|
) {
|
||||||
const tool = tools.get(event.id)
|
const tool = tools.get(event.id)
|
||||||
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${event.id}`))
|
||||||
if (tool.name !== event.name)
|
if (tool.name !== event.name)
|
||||||
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Duplicate tool input end: ${event.id}`))
|
||||||
yield* toolInput.end(event.id)
|
yield* toolInput.end(event.id, undefined, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failMalformedToolInput = Effect.fnUntraced(function* (event: {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly raw: string
|
||||||
|
}) {
|
||||||
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
|
const tool = tools.get(event.id)
|
||||||
|
if (!tool || tool.called || tool.settled)
|
||||||
|
return yield* Effect.die(new Error(`Malformed tool input after call settlement: ${event.id}`))
|
||||||
|
if (tool.name !== event.name)
|
||||||
|
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
|
||||||
|
if (toolInput.has(event.id)) yield* endToolInput(event, event.raw)
|
||||||
|
tool.settled = true
|
||||||
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
assistantMessageID: tool.assistantMessageID,
|
||||||
|
callID: event.id,
|
||||||
|
error: {
|
||||||
|
type: "tool.input-json",
|
||||||
|
message: "Tool call arguments were malformed JSON and were not executed. Retry with valid JSON.",
|
||||||
|
},
|
||||||
|
executed: false,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
const flush = Effect.fn("SessionRunner.flush")(function* () {
|
||||||
@@ -229,16 +262,18 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
return failed
|
return failed
|
||||||
})
|
})
|
||||||
|
|
||||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error) {
|
||||||
yield* flush()
|
yield* flush()
|
||||||
yield* failTools(error, "uncalled")
|
yield* failTools(error, "uncalled")
|
||||||
yield* startAssistant()
|
yield* startAssistant()
|
||||||
if (replace || stepFailure === undefined) stepFailure = error
|
if (stepFailure === undefined) stepFailure = error
|
||||||
})
|
})
|
||||||
|
|
||||||
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
|
const publishStepFailure = Effect.fnUntraced(function* (details?: {
|
||||||
readonly cost: Money.USD
|
readonly cost?: Money.USD
|
||||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
|
||||||
|
readonly snapshot?: Snapshot.ID
|
||||||
|
readonly files?: readonly RelativePath[]
|
||||||
}) {
|
}) {
|
||||||
if (stepFailed || stepFailure === undefined) return
|
if (stepFailed || stepFailure === undefined) return
|
||||||
const assistantMessageID = yield* startAssistant()
|
const assistantMessageID = yield* startAssistant()
|
||||||
@@ -247,7 +282,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
error: stepFailure,
|
error: stepFailure,
|
||||||
...usage,
|
...details,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -337,6 +372,10 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
yield* endToolInput(event)
|
yield* endToolInput(event)
|
||||||
return
|
return
|
||||||
|
case "tool-input-error":
|
||||||
|
retryEvidence = true
|
||||||
|
yield* failMalformedToolInput(event)
|
||||||
|
return
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
retryEvidence = true
|
retryEvidence = true
|
||||||
if (!tools.has(event.id)) yield* startToolInput(event)
|
if (!tools.has(event.id)) yield* startToolInput(event)
|
||||||
@@ -419,7 +458,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
|
||||||
if (event.reason === "content-filter") {
|
if (event.reason === "content-filter") {
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
|
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -427,7 +466,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
|||||||
return
|
return
|
||||||
case "provider-error":
|
case "provider-error":
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
yield* failAssistant({ type: "provider.unknown", message: event.message })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,14 +136,19 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
|
|||||||
: item.text.length > 0
|
: item.text.length > 0
|
||||||
? [{ type: "text", text: item.text }]
|
? [{ type: "text", text: item.text }]
|
||||||
: []
|
: []
|
||||||
|
const reuseToolProviderMetadata =
|
||||||
|
reuseProviderMetadata ||
|
||||||
|
(sameModel &&
|
||||||
|
item.executed === true &&
|
||||||
|
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
|
||||||
const call = toolCall(
|
const call = toolCall(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||||
)
|
)
|
||||||
if (item.executed !== true) return [call]
|
if (item.executed !== true) return [call]
|
||||||
const result = toolResult(
|
const result = toolResult(
|
||||||
item,
|
item,
|
||||||
reuseProviderMetadata
|
reuseToolProviderMetadata
|
||||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ export interface BoundInput {
|
|||||||
readonly output: ToolOutput
|
readonly output: ToolOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundResult {
|
|
||||||
readonly output: ToolOutput
|
|
||||||
readonly outputPaths: ReadonlyArray<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
|
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
|
||||||
operation: Schema.Literals(["encode", "write"]),
|
operation: Schema.Literals(["encode", "write"]),
|
||||||
cause: Schema.Defect(),
|
cause: Schema.Defect(),
|
||||||
@@ -41,7 +36,7 @@ export type Error = StorageError
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
||||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
|
readonly bound: (input: BoundInput) => Effect.Effect<ToolOutput, Error>
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
readonly cleanup: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,26 +145,20 @@ const layer = Layer.effect(
|
|||||||
lineCount(contextual) <= outputLimits.maxLines &&
|
lineCount(contextual) <= outputLimits.maxLines &&
|
||||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
||||||
)
|
)
|
||||||
return {
|
return input.output
|
||||||
output: input.output,
|
|
||||||
outputPaths: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputPath = yield* write(contextual)
|
const outputPath = yield* write(contextual)
|
||||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
output: {
|
structured: input.output.structured,
|
||||||
structured: input.output.structured,
|
content: [
|
||||||
content: [
|
{
|
||||||
{
|
type: "text" as const,
|
||||||
type: "text" as const,
|
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
},
|
||||||
},
|
...media,
|
||||||
...media,
|
],
|
||||||
],
|
|
||||||
},
|
|
||||||
outputPaths: [outputPath],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
|||||||
## Registration
|
## Registration
|
||||||
|
|
||||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
|
||||||
group, which flattens direct model names to `<group>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
|
||||||
`codemode: false` keeps the tool on the provider's native tool list).
|
`codemode: false` keeps the tool on the provider's native tool list).
|
||||||
|
|
||||||
Registrations are scoped:
|
Registrations are scoped:
|
||||||
@@ -55,4 +55,3 @@ Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOut
|
|||||||
## Current Gaps
|
## Current Gaps
|
||||||
|
|
||||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||||
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type CollectedFiles = {
|
|||||||
interface Registration {
|
interface Registration {
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly group?: string
|
readonly namespace?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||||
@@ -47,7 +47,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||||
hooks?: CodeMode.ToolCallHooks,
|
hooks?: CodeMode.ToolCallHooks,
|
||||||
) => {
|
) => {
|
||||||
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
|
const tools: Record<string, Tool.Definition<never>> = {}
|
||||||
for (const [name, registration] of registrations) {
|
for (const [name, registration] of registrations) {
|
||||||
const child = definition(name, registration.tool)
|
const child = definition(name, registration.tool)
|
||||||
const value = Tool.make({
|
const value = Tool.make({
|
||||||
@@ -56,24 +56,8 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
|||||||
output: child.outputSchema,
|
output: child.outputSchema,
|
||||||
run: (input) => invoke(name, registration, input),
|
run: (input) => invoke(name, registration, input),
|
||||||
})
|
})
|
||||||
if (registration.group === undefined) {
|
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||||
const path = registration.name
|
tools[path] = value
|
||||||
if (Object.hasOwn(tools, path)) throw new TypeError(`CodeMode tool namespace conflict: ${path}`)
|
|
||||||
tools[path] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const path = registration.name
|
|
||||||
const namespace = registration.group
|
|
||||||
const group = tools[namespace]
|
|
||||||
if (group && Tool.isDefinition(group)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}`)
|
|
||||||
if (group) {
|
|
||||||
if (Object.hasOwn(group, path)) throw new TypeError(`CodeMode tool namespace conflict: ${namespace}.${path}`)
|
|
||||||
group[path] = value
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const entries: Record<string, Tool.Definition<never>> = {}
|
|
||||||
entries[path] = value
|
|
||||||
tools[namespace] = entries
|
|
||||||
}
|
}
|
||||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export interface AfterEvent {
|
|||||||
readonly input: unknown
|
readonly input: unknown
|
||||||
result: ToolResultValue
|
result: ToolResultValue
|
||||||
output?: ToolOutput
|
output?: ToolOutput
|
||||||
outputPaths?: ReadonlyArray<string>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import { Tools } from "./tools"
|
|||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry group and permission action names for MCP tools.
|
* Registry namespace and permission action names for MCP tools.
|
||||||
*/
|
*/
|
||||||
export const group = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||||
export const name = (server: string, tool: string) => `${group(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -32,11 +32,11 @@ export const layer = Layer.effectDiscard(
|
|||||||
// registry never has a gap where MCP tools disappear mid-swap.
|
// registry never has a gap where MCP tools disappear mid-swap.
|
||||||
const reconcile = lock.withPermit(
|
const reconcile = lock.withPermit(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const groups = new Map<string, Record<string, Tool.AnyTool>>()
|
const groups = new Map<string, { tools: Record<string, Tool.AnyTool>; codemode: boolean }>()
|
||||||
for (const tool of yield* mcp.tools()) {
|
for (const tool of yield* mcp.tools()) {
|
||||||
const group = groups.get(tool.server) ?? {}
|
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
|
||||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||||
group[tool.name] = Tool.withPermission(
|
group.tools[tool.name] = Tool.withPermission(
|
||||||
Tool.make({
|
Tool.make({
|
||||||
description: tool.description ?? "",
|
description: tool.description ?? "",
|
||||||
jsonSchema: {
|
jsonSchema: {
|
||||||
@@ -107,7 +107,7 @@ export const layer = Layer.effectDiscard(
|
|||||||
const next = yield* Scope.fork(scope)
|
const next = yield* Scope.fork(scope)
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
groups,
|
groups,
|
||||||
([group, record]) => tools.register(record, { group }),
|
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
|
||||||
{
|
{
|
||||||
discard: true,
|
discard: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import { SessionSchema } from "../session/schema"
|
|||||||
import { ToolOutputStore } from "../tool-output-store"
|
import { ToolOutputStore } from "../tool-output-store"
|
||||||
import { Wildcard } from "../util/wildcard"
|
import { Wildcard } from "../util/wildcard"
|
||||||
import { ExecuteTool } from "./execute"
|
import { ExecuteTool } from "./execute"
|
||||||
import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool"
|
import {
|
||||||
|
definition,
|
||||||
|
permission,
|
||||||
|
registrationEntries,
|
||||||
|
RegistrationError,
|
||||||
|
settle,
|
||||||
|
validateNamespace,
|
||||||
|
type AnyTool,
|
||||||
|
} from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
import { ToolHooks } from "./hooks"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
@@ -47,7 +55,6 @@ export interface Materialization {
|
|||||||
export interface Settlement {
|
export interface Settlement {
|
||||||
readonly result: ToolResultValue
|
readonly result: ToolResultValue
|
||||||
readonly output?: ToolOutput
|
readonly output?: ToolOutput
|
||||||
readonly outputPaths?: ReadonlyArray<string>
|
|
||||||
readonly error?: SessionError.Error
|
readonly error?: SessionError.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +102,7 @@ const registryLayer = Layer.effect(
|
|||||||
type Registration = {
|
type Registration = {
|
||||||
readonly tool: AnyTool
|
readonly tool: AnyTool
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly group?: string
|
readonly namespace?: string
|
||||||
readonly codemode: boolean
|
readonly codemode: boolean
|
||||||
}
|
}
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||||
@@ -149,20 +156,13 @@ const registryLayer = Layer.effect(
|
|||||||
if ("result" in pending) {
|
if ("result" in pending) {
|
||||||
settlement = pending
|
settlement = pending
|
||||||
} else {
|
} else {
|
||||||
const bounded = yield* resources.bound({
|
const output = yield* resources.bound({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
callID: input.call.id,
|
callID: input.call.id,
|
||||||
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
|
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
|
||||||
})
|
})
|
||||||
const result = ToolOutput.toResultValue(bounded.output)
|
const result = ToolOutput.toResultValue(output)
|
||||||
settlement =
|
settlement = result.type === "error" ? { result } : { result, output }
|
||||||
result.type === "error"
|
|
||||||
? bounded.outputPaths.length > 0
|
|
||||||
? { result, outputPaths: bounded.outputPaths }
|
|
||||||
: { result }
|
|
||||||
: bounded.outputPaths.length > 0
|
|
||||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
|
||||||
: { result, output: bounded.output }
|
|
||||||
}
|
}
|
||||||
const afterEvent: ToolHooks.AfterEvent = {
|
const afterEvent: ToolHooks.AfterEvent = {
|
||||||
tool: input.call.name,
|
tool: input.call.name,
|
||||||
@@ -173,20 +173,19 @@ const registryLayer = Layer.effect(
|
|||||||
input: beforeEvent.input,
|
input: beforeEvent.input,
|
||||||
result: settlement.result,
|
result: settlement.result,
|
||||||
output: settlement.output,
|
output: settlement.output,
|
||||||
outputPaths: settlement.outputPaths,
|
|
||||||
}
|
}
|
||||||
yield* toolHooks.runAfter(afterEvent)
|
yield* toolHooks.runAfter(afterEvent)
|
||||||
return {
|
return {
|
||||||
result: afterEvent.result,
|
result: afterEvent.result,
|
||||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
|
||||||
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||||
const entries = registrationEntries(tools, options?.group)
|
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
|
||||||
|
const entries = registrationEntries(tools, options?.namespace)
|
||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
const codemode = options?.codemode ?? true
|
const codemode = options?.codemode ?? true
|
||||||
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
|
||||||
@@ -205,7 +204,7 @@ const registryLayer = Layer.effect(
|
|||||||
registration: {
|
registration: {
|
||||||
tool: entry.tool,
|
tool: entry.tool,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
group: entry.group,
|
namespace: entry.namespace,
|
||||||
codemode,
|
codemode,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"
|
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { LLM, Message } from "@opencode-ai/ai"
|
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||||
import { LLMClient } from "@opencode-ai/ai/route"
|
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(AISDK.locationLayer)
|
const it = testEffect(AISDK.locationLayer)
|
||||||
@@ -19,6 +19,37 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
|||||||
limit: { context: 100, output: 20 },
|
limit: { context: 100, output: 20 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||||
|
specificationVersion: "v3",
|
||||||
|
provider: "test",
|
||||||
|
modelId: "test",
|
||||||
|
supportedUrls: {},
|
||||||
|
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||||
|
doStream: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
stream: new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
events.forEach((event) => controller.enqueue(event))
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const usage = {
|
||||||
|
inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
outputTokens: { total: 1, text: 0, reasoning: 0 },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const client = LLMClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("keys language models by package and flattened overlays", () =>
|
it.effect("keys language models by package and flattened overlays", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
@@ -238,3 +269,68 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
|||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
|
const raw = '{"query":"partial'
|
||||||
|
yield* aisdk.hook.sdk((event) => {
|
||||||
|
event.sdk = {
|
||||||
|
languageModel: () =>
|
||||||
|
streamModel([
|
||||||
|
{ type: "tool-input-start", id: "call_1", toolName: "lookup" },
|
||||||
|
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||||
|
{ type: "tool-input-end", id: "call_1" },
|
||||||
|
{ type: "tool-call", toolCallId: "call_1", toolName: "lookup", input: raw },
|
||||||
|
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage },
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Lookup" })).pipe(
|
||||||
|
Effect.provide(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw,
|
||||||
|
})
|
||||||
|
expect(response.events.some(LLMEvent.is.toolInputEnd)).toBeTrue()
|
||||||
|
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
|
const raw = '{"query":"partial'
|
||||||
|
yield* aisdk.hook.sdk((event) => {
|
||||||
|
event.sdk = {
|
||||||
|
languageModel: () =>
|
||||||
|
streamModel([
|
||||||
|
{ type: "tool-input-start", id: "call_1", toolName: "web_search", providerExecuted: true },
|
||||||
|
{ type: "tool-input-delta", id: "call_1", delta: raw },
|
||||||
|
{ type: "tool-input-end", id: "call_1" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call_1",
|
||||||
|
toolName: "web_search",
|
||||||
|
input: raw,
|
||||||
|
providerExecuted: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = yield* aisdk.model(model("hosted-test-ai-sdk"))
|
||||||
|
const error = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Search" })).pipe(
|
||||||
|
Effect.provide(client),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(LLMError)
|
||||||
|
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user