Compare commits

..
Author SHA1 Message Date
Simon Klee f5d3eb7237 feat(tui): add mini spinner choices and previews 2026-08-31 15:36:08 +02:00
Simon Klee 829de0d1ff feat(tui): make mini work spinner configurable 2026-08-31 15:10:46 +02:00
Simon Klee 807ad7d6af remove stupid tests 2026-08-31 14:15:41 +02:00
Simon Klee 7681326abb feat(tui): add one-cell mini activity indicators 2026-08-31 14:03:03 +02:00
Simon Klee c28d4e1fa5 fix(tui): prevent exit confirmation crash 2026-08-31 08:17:55 +02:00
Simon Klee 77fb7106b9 feat(tui): add image support to mini 2026-08-31 07:59:27 +02:00
Simon Klee fc8fae90b3 fix(tui): adapt mini to size and mode changes
Fixed-size layouts can hide essential controls in small terminals.
Keep prompts and actions visible across resizes, and make overflowing
content accessible by scrolling. Apply monochrome settings immediately
so users can switch modes without restarting or disrupting active
Markdown output.
2026-08-30 22:21:09 +02:00
Simon Klee b8793a1783 feat(tui): refactor mini layout 2026-08-30 20:10:35 +02:00
217 changed files with 7815 additions and 11748 deletions
-1
View File
@@ -46,7 +46,6 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles
- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
-1
View File
@@ -183,7 +183,6 @@
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
"x86_64-linux": "sha256-YAKhbMPKeXAZLYgJzLRQi/fwIMjPOkVIvOWkG3mCgTE=",
"aarch64-linux": "sha256-UnIhoAleUQXKP8E0pVG0OyEpugmKEhfh6fEvpkpN2QI=",
"aarch64-darwin": "sha256-m68nJKpcVrX9R6EZqVvPKgRmZEXilJGcISMaQpN/k+Q=",
"x86_64-darwin": "sha256-DEjeoEn10K5YdJJCWtLw7LtthXChUXc0Vniccl/6KIc="
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -831,7 +831,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicUserBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -841,7 +840,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
}
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
}
if (content.length > 0) messages.push({ role: "user", content })
messages.push({ role: "user", content })
continue
}
@@ -849,7 +848,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
const content: AnthropicAssistantBlock[] = []
for (const part of message.content) {
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
continue
}
@@ -893,7 +891,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
`Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`,
)
}
if (content.length > 0) messages.push({ role: "assistant", content })
messages.push({ role: "assistant", content })
continue
}
@@ -1021,11 +1019,10 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
)
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter((part) => part.text.length > 0)
const system =
systemParts.length === 0
request.system.length === 0
? undefined
: systemParts.map((part) => ({
: request.system.map((part) => ({
type: "text" as const,
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
+72 -129
View File
@@ -1,4 +1,4 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
@@ -262,28 +262,24 @@ const providerMetadata = (key: string, metadata: Record<string, unknown>): Provi
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (part.encrypted !== undefined) return part.encrypted
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
return (
part.encrypted ??
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
)
}
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
}
const removeEmptyToolInputKeys = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(removeEmptyToolInputKeys)
if (!ProviderShared.isRecord(input)) return input
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => (key === "" ? [] : [[key, removeEmptyToolInputKeys(value)]])),
)
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
? metadata.redactedData
: undefined
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
name: part.name,
input: removeEmptyToolInputKeys(part.input),
input: part.input,
},
})
@@ -418,12 +414,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
return content.length === 0 ? undefined : content
}
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
@@ -431,42 +422,38 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints()
const toolConfig = (() => {
if (request.tools.length === 0) return undefined
return {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
})()
const system = lowerSystem(breakpoints, request.system)
const toolConfig =
request.tools.length > 0
? {
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
}
: undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints)
if (breakpoints.dropped > 0) {
yield* Effect.logWarning(
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
)
}
const inferenceConfig = (() => {
if (
generation?.maxTokens === undefined &&
generation?.temperature === undefined &&
generation?.topP === undefined &&
(generation?.stop === undefined || generation.stop.length === 0)
)
return undefined
return {
maxTokens: generation?.maxTokens,
temperature: generation?.temperature,
topP: generation?.topP,
stopSequences: generation?.stop,
}
})()
return {
modelId: request.model.id,
messages,
system,
inferenceConfig,
inferenceConfig:
generation?.maxTokens === undefined &&
generation?.temperature === undefined &&
generation?.topP === undefined &&
(generation?.stop === undefined || generation.stop.length === 0)
? undefined
: {
maxTokens: generation?.maxTokens,
temperature: generation?.temperature,
topP: generation?.topP,
stopSequences: generation?.stop,
},
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
@@ -516,16 +503,6 @@ interface ParserState {
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
}
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => {
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0))
chunks.reduce((offset, chunk) => {
bytes.set(chunk, offset)
return offset + chunk.length
}, 0)
return Encoding.encodeBase64(bytes)
}
const step = (state: ParserState, event: BedrockEvent) =>
@@ -573,46 +550,23 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedChunks = yield* (() => {
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
"Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
undefined,
cause,
),
),
)
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
})()
const reasoningRedactedContent = (() => {
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (reasoning.data === undefined) return state.reasoningRedactedContent
return Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
)
})()
const reasoningSignatures = (() => {
if (!reasoning.signature) return state.reasoningSignatures
return { ...state.reasoningSignatures, [index]: reasoning.signature }
})()
const redactedData = reasoning.redactedContent ?? reasoning.data
const metadata = reasoning.signature
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
: redactedData !== undefined
? providerMetadata(state.providerMetadataKey, { redactedData })
: undefined
const lifecycle =
reasoning.text !== undefined || metadata !== undefined
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
: state.lifecycle
return [
{
...state,
lifecycle,
reasoningSignatures,
reasoningRedactedContent,
reasoningSignatures: reasoning.signature
? { ...state.reasoningSignatures, [index]: reasoning.signature }
: state.reasoningSignatures,
},
events,
] as const
@@ -640,24 +594,16 @@ const step = (state: ParserState, event: BedrockEvent) =>
const result = yield* ToolStream.finish(ADAPTER, state.tools, index)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = (() => {
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
const metadata = (() => {
const signature = state.reasoningSignatures[index]
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
const redactedContent = state.reasoningRedactedContent[index]
if (redactedContent)
return providerMetadata(state.providerMetadataKey, {
redactedData: encodeRedactedContent(redactedContent),
})
})()
return Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events,
`reasoning-${index}`,
metadata,
)
})()
const lifecycle = resultEvents.length
? Lifecycle.stepStart(state.lifecycle, events)
: Lifecycle.reasoningEnd(
Lifecycle.textEnd(state.lifecycle, events, `text-${index}`),
events,
`reasoning-${index}`,
state.reasoningSignatures[index]
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
: undefined,
)
events.push(...resultEvents)
return [
{
@@ -671,9 +617,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
reasoningRedactedContent: Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
),
},
events,
] as const
@@ -735,22 +678,23 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
normalized,
},
usage: state.pendingFinish.usage,
})
return events
}
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
state.pendingFinish
? (() => {
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
normalized:
state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls
? "tool-calls"
: state.pendingFinish.reason.normalized,
},
usage: state.pendingFinish.usage,
})
return events
})()
: []
// =============================================================================
// Protocol And Bedrock Route
@@ -775,7 +719,6 @@ export const protocol = Protocol.make({
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
reasoningRedactedContent: {},
}),
step,
onHalt: (state) => Effect.succeed(onHalt(state)),
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { Effect, Encoding, Stream } from "effect"
import { AIError, AIErrorReason, InvalidProviderOutputError } from "../schema/index.js"
import { AIError, AIErrorReason } from "../schema/index.js"
import { Framing } from "../route/framing.js"
import { ProviderShared } from "./shared.js"
@@ -22,10 +22,6 @@ interface FrameBufferState {
const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 }
type FrameInput = { readonly _tag: "Chunk"; readonly bytes: Uint8Array } | { readonly _tag: "End" }
const endOfStream: FrameInput = { _tag: "End" }
const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => {
const remaining = state.buffer.length - state.offset
// Compact: drop the consumed prefix and append the new chunk in one alloc.
@@ -37,23 +33,9 @@ const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferSta
return { buffer: next, offset: 0 }
}
const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameInput) =>
const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) =>
Effect.gen(function* () {
if (input._tag === "End") {
const remaining = state.buffer.subarray(state.offset)
if (remaining.length > 0)
return yield* new AIError({
reason: new InvalidProviderOutputError({
route,
classification: "incomplete-stream",
message: `Incomplete Bedrock Converse event-stream frame: ${remaining.length} buffered bytes remain at end of stream`,
body: Encoding.encodeBase64(remaining),
}),
})
return [state, []] as const
}
let cursor = appendChunk(state, input.bytes)
let cursor = appendChunk(state, chunk)
const out: object[] = []
while (cursor.buffer.length - cursor.offset >= 4) {
const view = cursor.buffer.subarray(cursor.offset)
@@ -131,12 +113,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, input: FrameI
export const framing = (route: string): Framing.Definition<object> => ({
id: "aws-event-stream",
body: (frame) => ("rawBody" in frame && typeof frame.rawBody === "string" ? frame.rawBody : undefined),
frame: (bytes) =>
bytes.pipe(
Stream.map((bytes): FrameInput => ({ _tag: "Chunk", bytes })),
Stream.concat(Stream.succeed(endOfStream)),
Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route)),
),
frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))),
})
export * as BedrockEventStream from "./bedrock-event-stream.js"
+24 -80
View File
@@ -240,10 +240,6 @@ interface ParserState {
readonly lifecycle: Lifecycle.State
readonly reasoningSignature?: string
readonly textSignature?: string
readonly reasoningId?: string
readonly textId?: string
readonly nextReasoningId: number
readonly nextTextId: number
readonly seenCallIds?: ReadonlySet<string>
}
@@ -575,23 +571,19 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
let lifecycle = state.lifecycle
if (state.reasoningId !== undefined)
if (state.reasoningSignature !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
state.reasoningId,
state.reasoningSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
"reasoning-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textId !== undefined)
if (state.textSignature !== undefined)
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
state.textId,
state.textSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
"text-0",
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -640,10 +632,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
let lifecycle = nextState.lifecycle
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
let reasoningId = nextState.reasoningId
let textId = nextState.textId
let nextReasoningId = nextState.nextReasoningId
let nextTextId = nextState.nextTextId
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
@@ -669,51 +657,27 @@ const step = (state: ParserState, event: GeminiEvent) => {
else if (signature !== undefined && "text" in part) textSignature = signature
if ("text" in part && part.text.length > 0) {
if (part.thought) {
if (textId !== undefined) {
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
textId,
textSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textId = undefined
textSignature = undefined
}
if (reasoningId === undefined) {
reasoningId = `reasoning-${nextReasoningId}`
nextReasoningId += 1
}
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
reasoningId,
"reasoning-0",
part.text,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
)
continue
}
if (reasoningId !== undefined) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
}
if (textId === undefined) {
textId = `text-${nextTextId}`
nextTextId += 1
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.textDelta(
lifecycle,
events,
textId,
"text-0",
part.text,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
@@ -731,28 +695,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
if (supplied !== undefined) seenCallIds.add(supplied)
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
if (reasoningId !== undefined) {
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
reasoningSignature = undefined
}
if (textId !== undefined) {
lifecycle = Lifecycle.textEnd(
lifecycle,
events,
textId,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
)
textId = undefined
textSignature = undefined
}
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.toolCall({
@@ -775,10 +725,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
reasoningSignature,
textSignature,
reasoningId,
textId,
nextReasoningId,
nextTextId,
seenCallIds,
finishReason: candidate.finishReason ?? nextState.finishReason,
},
@@ -806,8 +752,6 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
nextReasoningId: 0,
nextTextId: 0,
}),
step,
onHalt: (state) => Effect.succeed(finish(state)),
-1
View File
@@ -1,7 +1,6 @@
export * as AnthropicMessages from "./anthropic-messages.js"
export * as BedrockConverse from "./bedrock-converse.js"
export * as Gemini from "./gemini.js"
export * as MistralChat from "./mistral-chat.js"
export * as OpenAIChat from "./openai-chat.js"
export * as OpenAIImages from "./openai-images.js"
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
-780
View File
@@ -1,780 +0,0 @@
import { Effect, Schema } from "effect"
import { Auth } from "../route/auth.js"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
InvalidProviderOutputError,
LLMEvent,
Usage,
type FinishReasonDetails,
type LLMRequest,
type MediaPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
const DONE = "[DONE]" as const
const TOOL_ID = /^[A-Za-z0-9]{9}$/
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
export const PATH = "/chat/completions"
const MistralTextContent = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
})
const MistralThinkingUnit = Schema.StructWithRest(
Schema.Struct({
type: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralThinkingUnit = Schema.Schema.Type<typeof MistralThinkingUnit>
const MistralThinkingContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.Literal("thinking"),
thinking: Schema.Array(MistralThinkingUnit),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralThinkingContent = Schema.Schema.Type<typeof MistralThinkingContent>
const isMistralThinkingContent = Schema.is(MistralThinkingContent)
const MistralUserContent = Schema.Union([
MistralTextContent,
Schema.Struct({ type: Schema.Literal("image_url"), image_url: Schema.String }),
Schema.Struct({ type: Schema.Literal("document_url"), document_url: Schema.String }),
])
type MistralUserContent = Schema.Schema.Type<typeof MistralUserContent>
const MistralAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.Literal("function"),
function: Schema.Struct({ name: Schema.String, arguments: Schema.String }),
})
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.Union([Schema.String, Schema.Array(Schema.Union([MistralTextContent, MistralThinkingContent]))]),
tool_calls: optionalArray(MistralAssistantToolCall),
prefix: Schema.optional(Schema.Literal(true)),
}),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
name: Schema.String,
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
}),
]).pipe(Schema.toTaggedUnion("role"))
type MistralMessage = Schema.Schema.Type<typeof MistralMessage>
const MistralTool = Schema.Struct({
type: Schema.Literal("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.Literal(false),
}),
})
type MistralTool = Schema.Schema.Type<typeof MistralTool>
const MistralOptions = Schema.Struct({
safePrompt: Schema.optional(Schema.Boolean),
documentImageLimit: Schema.optional(Schema.Number),
documentPageLimit: Schema.optional(Schema.Number),
parallelToolCalls: Schema.optional(Schema.Boolean),
reasoningEffort: Schema.optional(Schema.String),
promptMode: Schema.optional(Schema.Literal("reasoning")),
promptCacheKey: Schema.optional(Schema.String),
})
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | (string & {})
export type ProviderOptionsInput = {
readonly safePrompt?: boolean
readonly documentImageLimit?: number
readonly documentPageLimit?: number
readonly parallelToolCalls?: boolean
readonly reasoningEffort?: ReasoningEffort
readonly promptMode?: "reasoning"
readonly promptCacheKey?: string
readonly [key: string]: unknown
}
const MistralBody = Schema.Struct({
model: Schema.String,
messages: Schema.Array(MistralMessage),
tools: optionalArray(MistralTool),
tool_choice: Schema.optional(
Schema.Union([
Schema.Literals(["auto", "none", "any"]),
Schema.Struct({ type: Schema.Literal("function"), function: Schema.Struct({ name: Schema.String }) }),
]),
),
stream: Schema.Literal(true),
max_tokens: Schema.optional(Schema.Number),
random_seed: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
stop: optionalArray(Schema.String),
prompt_cache_key: Schema.optional(Schema.String),
safe_prompt: Schema.optional(Schema.Boolean),
document_image_limit: Schema.optional(Schema.Number),
document_page_limit: Schema.optional(Schema.Number),
parallel_tool_calls: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(Schema.String),
prompt_mode: Schema.optional(Schema.Literal("reasoning")),
})
export type MistralBody = Schema.Schema.Type<typeof MistralBody>
const MistralUsageDetails = Schema.StructWithRest(Schema.Struct({ cached_tokens: optionalNull(Schema.Number) }), [
Schema.Record(Schema.String, Schema.Unknown),
])
const MistralUsage = Schema.StructWithRest(
Schema.Struct({
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
num_cached_tokens: optionalNull(Schema.Number),
prompt_token_details: optionalNull(MistralUsageDetails),
prompt_tokens_details: optionalNull(MistralUsageDetails),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralOutputContent = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
text: optionalNull(Schema.String),
thinking: optionalNull(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralOutputContent = Schema.Schema.Type<typeof MistralOutputContent>
const MistralToolDelta = Schema.Struct({
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(
Schema.Struct({
name: optionalNull(Schema.String),
arguments: optionalNull(Schema.Union([Schema.String, JsonObject])),
}),
),
})
type MistralToolDelta = Schema.Schema.Type<typeof MistralToolDelta>
const MistralChoice = Schema.StructWithRest(
Schema.Struct({
delta: optionalNull(
Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.Union([Schema.String, Schema.Array(MistralOutputContent)])),
tool_calls: optionalNull(Schema.Array(MistralToolDelta)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
finish_reason: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralError = Schema.StructWithRest(
Schema.Struct({
message: Schema.String,
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const MistralEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(MistralChoice)),
usage: optionalNull(MistralUsage),
error: optionalNull(MistralError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const hashID = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
const toolIDNormalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (TOOL_ID.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hashID(id)
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
if (media.mime.startsWith("image/")) return { type: "image_url" as const, image_url: url }
if (media.mime === "application/pdf") return { type: "document_url" as const, document_url: url }
return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`)
})
const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message: LLMRequest["messages"][number]) {
const content: MistralUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text })
continue
}
if (part.type === "media") {
content.push(yield* lowerMedia(part))
continue
}
return yield* ProviderShared.unsupportedContent("Mistral Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
return { role: "user" as const, content }
})
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): MistralAssistantToolCall => ({
id: normalizeID(part.id),
type: "function",
function: { name: part.name, arguments: ProviderShared.encodeJson(part.input) },
})
const lowerAssistant = Effect.fn("MistralChat.lowerAssistant")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
prefix: boolean,
) {
const structured = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const content: Array<Schema.Schema.Type<typeof MistralTextContent> | MistralThinkingContent> = []
const text: string[] = []
const toolCalls: MistralAssistantToolCall[] = []
for (const part of message.content) {
if (part.type === "text") {
if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
continue
}
if (part.type === "reasoning") {
const native = part.providerMetadata?.mistral?.thinking
if (structured && isMistralThinkingContent(native)) content.push(native)
else if (structured) content.push({ type: "text", text: part.text })
else text.push(part.text)
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part, normalizeID))
continue
}
return yield* ProviderShared.unsupportedContent("Mistral Chat", "assistant", ["text", "reasoning", "tool-call"])
}
return {
role: "assistant" as const,
content: structured ? content : text.join(""),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(prefix ? { prefix: true as const } : {}),
}
})
const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
) {
const output: MistralMessage[] = []
for (const part of message.content) {
if (part.type !== "tool-result")
return yield* ProviderShared.unsupportedContent("Mistral Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
output.push({
role: "tool",
tool_call_id: normalizeID(part.id),
name: part.name,
content: ProviderShared.toolResultText(part),
})
continue
}
const content: MistralUserContent[] = []
for (const item of part.result.value) {
if (item.type === "text") {
content.push({ type: "text", text: item.text })
continue
}
content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))
}
output.push({
role: "tool",
tool_call_id: normalizeID(part.id),
name: part.name,
content: content.some((item) => item.type !== "text")
? content
: content.map((item) => (item.type === "text" ? item.text : "")).join(""),
})
}
return output
})
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = toolIDNormalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
messages.push({
role: "user",
content: update.text,
})
continue
}
if (message.role === "user") {
messages.push(yield* lowerUser(message))
continue
}
if (message.role === "assistant") {
const hasToolCalls = message.content.some((part) => part.type === "tool-call")
const hasNativeThinking = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
const text = message.content
.flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
.join("")
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
continue
}
messages.push(...(yield* lowerToolResults(message, normalizeID)))
}
return messages
})
const lowerTool = (tool: ToolDefinition): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))(
request.providerOptions ?? {},
)
const selected = request.toolChoice?.type === "tool" ? request.toolChoice.name : undefined
if (request.toolChoice?.type === "tool" && !selected)
return yield* ProviderShared.invalidRequest("Mistral Chat tool choice requires a tool name")
if (options.reasoningEffort !== undefined && options.promptMode !== undefined)
return yield* ProviderShared.invalidRequest(
"Mistral Chat reasoningEffort and promptMode provider options are mutually exclusive",
)
const toolChoice = request.toolChoice
? yield* ProviderShared.matchToolChoice("Mistral Chat", request.toolChoice, {
auto: () => "auto" as const,
none: () => "none" as const,
required: () => "any" as const,
tool: (name) => ({ type: "function" as const, function: { name } }),
})
: undefined
return {
model: request.model.id,
messages: yield* lowerMessages(request),
tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
random_seed: request.generation?.seed,
temperature: request.generation?.temperature,
top_p: request.generation?.topP,
frequency_penalty: request.generation?.frequencyPenalty,
presence_penalty: request.generation?.presencePenalty,
stop: request.generation?.stop,
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
safe_prompt: options.safePrompt,
document_image_limit: options.documentImageLimit,
document_page_limit: options.documentPageLimit,
parallel_tool_calls:
options.parallelToolCalls ?? (request.toolChoice?.disableParallelToolUse === true ? false : undefined),
reasoning_effort: options.reasoningEffort,
prompt_mode: options.promptMode,
}
})
type ToolKey = string | number
interface PendingTool {
readonly id: string
readonly name?: string
readonly input: string
}
interface ActiveContent {
readonly type: "text" | "reasoning"
readonly id: string
readonly thinking?: MistralThinkingContent
}
export interface ParserState {
readonly tools: ToolStream.State<ToolKey>
readonly pendingTools: Partial<Record<ToolKey, PendingTool>>
readonly toolIDs: ReadonlyMap<string, string>
readonly usedToolIDs: ReadonlySet<string>
readonly completedTools: ReadonlyArray<LLMEvent>
readonly latestToolKey?: ToolKey
readonly generatedTools: number
readonly lifecycle: Lifecycle.State
readonly active?: ActiveContent
readonly nextContent: number
readonly usage?: Usage
readonly finishReason?: FinishReasonDetails
}
const mapUsage = (usage: MistralEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const input = usage.prompt_tokens ?? undefined
const reported =
usage.num_cached_tokens ??
usage.prompt_tokens_details?.cached_tokens ??
usage.prompt_token_details?.cached_tokens ??
undefined
const cached = input === undefined || reported === undefined ? undefined : Math.max(0, Math.min(input, reported))
const output = usage.completion_tokens ?? undefined
return new Usage({
inputTokens: input,
outputTokens: output,
nonCachedInputTokens: ProviderShared.subtractTokens(input, cached),
cacheReadInputTokens: cached,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { mistral: usage },
})
}
const mapFinishReason = (reason: string) => {
switch (reason) {
case "stop":
return "stop" as const
case "length":
case "model_length":
return "length" as const
case "tool_calls":
return "tool-calls" as const
case "content_filter":
return "content-filter" as const
case "error":
case "network_error":
return "error" as const
default:
return "unknown" as const
}
}
const thinkingUnits = (value: unknown): ReadonlyArray<MistralThinkingUnit> => {
if (typeof value === "string") return [{ type: "text", text: value }]
if (!Array.isArray(value)) return []
return value.filter(Schema.is(MistralThinkingUnit))
}
const thinkingText = (thinking: ReadonlyArray<MistralThinkingUnit>) =>
thinking.flatMap((unit) => (typeof unit.text === "string" ? [unit.text] : [])).join("")
const thinkingMetadata = (thinking: MistralThinkingContent) => ({ mistral: { thinking } })
const closeActive = (state: ParserState, events: LLMEvent[]) => {
if (!state.active) return state
const lifecycle =
state.active.type === "text"
? Lifecycle.textEnd(state.lifecycle, events, state.active.id)
: Lifecycle.reasoningEnd(
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
)
return { ...state, lifecycle, active: undefined }
}
const appendText = (state: ParserState, events: LLMEvent[], text: string) => {
if (text.length === 0) return state
const current = state.active?.type === "text" ? state : closeActive(state, events)
const active = current.active ?? { type: "text" as const, id: `text-${current.nextContent}` }
return {
...current,
lifecycle: Lifecycle.textDelta(current.lifecycle, events, active.id, text),
active,
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOutputContent) => {
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
const units = thinkingUnits(part.thinking)
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
const thinking = {
...active.thinking,
...part,
type: "thinking" as const,
thinking: [...(active.thinking?.thinking ?? []), ...units],
}
const text = thinkingText(units)
return {
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
const appendContent = (
state: ParserState,
events: LLMEvent[],
content: string | ReadonlyArray<MistralOutputContent>,
) => {
if (typeof content === "string") return appendText(state, events, content)
return content.reduce((current, part) => {
if (part.type === "text") return appendText(current, events, part.text ?? "")
if (part.type === "thinking") return appendThinking(current, events, part)
return closeActive(current, events)
}, state)
}
const normalizeStreamToolID = (state: ParserState, source: string) => {
if (TOOL_ID.test(source))
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
const previous = state.toolIDs.get(source)
if (previous) return { id: previous, state }
let attempt = 0
let id = hashID(source)
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
return {
id,
state: {
...state,
toolIDs: new Map([...state.toolIDs, [source, id]]),
usedToolIDs: new Set([...state.usedToolIDs, id]),
},
}
}
const toolText = (tool: MistralToolDelta) => {
const value = tool.function?.arguments
if (typeof value === "string") return value
return value === null || value === undefined ? "" : ProviderShared.encodeJson(value)
}
const appendTools = Effect.fn("MistralChat.appendTools")(function* (
initial: ParserState,
events: LLMEvent[],
deltas: ReadonlyArray<MistralToolDelta>,
) {
if (deltas.length === 0) return initial
let state = closeActive(initial, events)
for (const [position, delta] of deltas.entries()) {
const wireID = delta.id?.trim() || undefined
const providedID = wireID === "null" ? undefined : wireID
const key =
delta.index ??
(providedID
? `id:${providedID}`
: deltas.length > 1
? `position:${position}`
: (state.latestToolKey ?? `missing:${state.generatedTools}`))
const existing = state.tools[key]
const pending = state.pendingTools[key]
const source = providedID ?? `generated:${String(key)}`
const normalized =
existing || pending ? { id: existing?.id ?? pending?.id ?? "", state } : normalizeStreamToolID(state, source)
state = normalized.state
const name = existing?.name ?? pending?.name ?? (delta.function?.name?.trim() || undefined)
const text = `${pending?.input ?? ""}${toolText(delta)}`
if (!name) {
state = {
...state,
pendingTools: { ...state.pendingTools, [key]: { id: normalized.id, input: text } },
latestToolKey: key,
generatedTools: state.generatedTools + (!providedID && !pending ? 1 : 0),
}
continue
}
const result = ToolStream.appendOrStart(
ADAPTER,
state.tools,
key,
{ id: normalized.id, name, text },
"Mistral Chat tool call delta is missing a name",
)
if (ToolStream.isError(result)) return yield* result
if (result.events.length > 0) state = { ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }
events.push(...result.events)
const pendingTools = { ...state.pendingTools }
delete pendingTools[key]
state = {
...state,
tools: result.tools,
pendingTools,
latestToolKey: key,
generatedTools: state.generatedTools + (!providedID && !existing && !pending ? 1 : 0),
}
}
return state
})
const hasLateContent = (event: MistralEvent) => {
const delta = event.choices?.[0]?.delta
if (typeof delta?.content === "string" && delta.content.length > 0) return true
if (Array.isArray(delta?.content) && delta.content.length > 0) return true
return (delta?.tool_calls ?? []).some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || tool.function?.arguments !== undefined,
)
}
const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: MistralEvent) {
if (event.error) {
const body = ProviderShared.encodeJson(event)
return yield* new AIError({
reason: classifyProviderFailure({
message: event.error.message,
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
})
}
const events: LLMEvent[] = []
const usage = mapUsage(event.usage) ?? state.usage
if (state.finishReason) {
if (hasLateContent(event))
return yield* ProviderShared.eventError(
ADAPTER,
"Mistral Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return [{ ...state, usage }, events] as const
}
const choice = event.choices?.[0]
const withContent = choice?.delta?.content == null ? state : appendContent(state, events, choice.delta.content)
const withTools = yield* appendTools(withContent, events, choice?.delta?.tool_calls ?? [])
if (!choice?.finish_reason) return [{ ...withTools, usage }, events] as const
const finishReason = {
normalized: mapFinishReason(choice.finish_reason),
raw: choice.finish_reason,
}
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
return yield* ProviderShared.eventError(
ADAPTER,
"Mistral Chat tool call delta is missing a name",
ProviderShared.encodeJson(event),
)
const finished =
!incomplete && Object.keys(withTools.tools).length > 0
? yield* ToolStream.finishAll(ADAPTER, withTools.tools)
: undefined
return [
{
...withTools,
tools: finished?.tools ?? withTools.tools,
completedTools: finished?.events ?? withTools.completedTools,
usage,
finishReason,
},
events,
] as const
})
const finishEvents = Effect.fn("MistralChat.finishEvents")(function* (state: ParserState) {
if (!state.finishReason)
return yield* new AIError({
reason: new InvalidProviderOutputError({
message: "Mistral Chat stream ended without finish_reason",
classification: "incomplete-stream",
route: ADAPTER,
}),
})
const events: LLMEvent[] = []
const closed = closeActive(state, events)
const lifecycle = closed.completedTools.length > 0 ? Lifecycle.stepStart(closed.lifecycle, events) : closed.lifecycle
events.push(...closed.completedTools)
const reason =
state.finishReason.normalized === "stop" && closed.completedTools.some(LLMEvent.is.toolCall)
? { ...state.finishReason, normalized: "tool-calls" as const }
: state.finishReason
Lifecycle.finish(lifecycle, events, { reason, usage: closed.usage })
return events
})
export const protocol = Protocol.make({
id: ADAPTER,
body: { schema: MistralBody, from: fromRequest },
stream: {
event: MistralStreamEvent,
initial: (): ParserState => ({
tools: ToolStream.empty<ToolKey>(),
pendingTools: {},
toolIDs: new Map(),
usedToolIDs: new Set(),
completedTools: [],
generatedTools: 0,
lifecycle: Lifecycle.initial(),
nextContent: 0,
}),
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === DONE,
onHalt: finishEvents,
},
})
export const framing = Framing.sseWithDone
export const httpTransport = HttpTransport.sseJson.with<MistralBody>().with({ framing })
export const route = Route.make({
id: ADAPTER,
provider: "mistral",
providerMetadataKey: "mistral",
protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
transport: httpTransport,
})
export * as MistralChat from "./mistral-chat.js"
+4 -2
View File
@@ -577,8 +577,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
input.push({
role: "user",
content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
})
continue
}
@@ -1,4 +1,4 @@
import { Effect, Encoding, Schema } from "effect"
import { Effect, Schema } from "effect"
import type { MediaPart } from "../../schema/index.js"
import { ProviderShared } from "../shared.js"
@@ -57,16 +57,6 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
Effect.mapError((cause) =>
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
),
)
return Encoding.encodeBase64(bytes)
})
// Route by MIME. Known image/document formats lower into a typed block; anything
// else fails with a clear error instead of silently degrading to a malformed
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
@@ -76,7 +66,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
const media = ProviderShared.normalizeMedia(part)
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
@@ -84,7 +75,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
const media = ProviderShared.normalizeMedia(part)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
-1
View File
@@ -13,7 +13,6 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
export * as GoogleVertexMessages from "./google-vertex-messages.js"
export * as GoogleVertexResponses from "./google-vertex-responses.js"
export * as Groq from "./groq.js"
export * as Mistral from "./mistral.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
-51
View File
@@ -1,51 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { MistralChat } from "../protocols/mistral-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("mistral")
export type ProviderOptions = MistralChat.ProviderOptionsInput
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export const route = MistralChat.route
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? MistralChat.DEFAULT_BASE_URL },
auth: AuthOptions.bearer(input, "MISTRAL_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) => configured.model<ProviderOptions>({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as Mistral from "./mistral.js"
+7 -12
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
@@ -170,19 +169,17 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
const messages = normalizeToolHistory(request.messages)
const normalized = messages === request.messages ? request : LLMRequest.update(request, { messages })
const routeDefaults = normalized.model.route.defaults
const modelDefaults = normalized.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, normalized.generation)
return LLMRequest.update(normalized, {
const routeDefaults = request.model.route.defaults
const modelDefaults = request.model.defaults
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
return LLMRequest.update(request, {
generation: generation ?? new GenerationOptions({}),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
modelDefaults?.providerOptions,
normalized.providerOptions,
request.providerOptions,
),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, normalized.http),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
})
}
@@ -449,9 +446,7 @@ export function make<Body, Prepared, Frame, Event, State>(
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const original = applyCachePolicy(resolveRequestOptions(request))
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const route = resolved.model.route
const body = yield* route.body
-74
View File
@@ -1,74 +0,0 @@
import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js"
const EMPTY_TOOL_OUTPUT = "(no tool output)"
const MISSING_TOOL_RESULT = "Tool result missing"
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
const pending = new Map<string, ToolCallPart>()
const appendMissingResults = () => {
if (pending.size === 0) return
normalized.push(missingToolResults(pending.values()))
pending.clear()
}
for (const message of messages) {
if (message.role === "user" || message.role === "assistant") appendMissingResults()
if (message.role === "tool") {
const tool = normalizeToolMessage(message, pending)
if (tool) normalized.push(tool)
continue
}
normalized.push(message)
if (message.role !== "assistant") continue
for (const part of message.content) {
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
}
}
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
? messages
: normalized
}
function missingToolResults(calls: Iterable<ToolCallPart>) {
return new Message({
role: "tool",
content: [...calls].map((call) =>
ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }),
),
})
}
function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPart>): Message | undefined {
const content = message.content.map((part) => {
if (part.type !== "tool-result" || part.providerExecuted === true) return part
const call = pending.get(part.id)
if (call) pending.delete(part.id)
return normalizeToolResult(part, call?.name ?? part.name)
})
if (content.length === 0) return undefined
if (content.every((part, index) => part === message.content[index])) return message
return new Message({
id: message.id,
role: message.role,
content,
metadata: message.metadata,
native: message.native,
})
}
function normalizeToolResult(part: ToolResultPart, name: string): ToolResultPart {
const named = part.name === name ? part : { ...part, name }
if (named.result.type === "text" && named.result.value === "")
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type === "error" && named.result.value === "")
return { ...named, result: { type: "error", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type !== "content") return named
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (value.length === named.result.value.length) return named
return { ...named, result: { type: "content", value } }
}
+1 -50
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -77,55 +77,6 @@ describe("request option precedence", () => {
}),
)
it.effect("keeps the last tool definition for duplicate names", () =>
Effect.gen(function* () {
const request = LLM.request({
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
prompt: "Use a tool.",
})
const prepared = yield* compileRequest(
LLMRequest.update(request, {
tools: [
ToolDefinition.make({ name: "lookup", description: "old", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "search", description: "search", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "lookup", description: "new", inputSchema: { type: "object" } }),
],
}),
)
expect(prepared.body.tools).toEqual([
{
type: "function",
function: { name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
},
{
type: "function",
function: { name: "search", description: "search", parameters: { type: "object" }, strict: false },
},
])
}),
)
it.effect("normalizes tool history before protocol lowering", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route.model({ id: "gpt-4o-mini" }),
messages: [
Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
Message.user("Continue."),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: "call_1", function: { name: "lookup" } }] },
{ role: "tool", tool_call_id: "call_1", content: "Tool result missing" },
{ role: "user", content: "Continue." },
])
}),
)
it.effect("applies model HTTP defaults before request HTTP overlays", () =>
LLMClient.generate(
LLM.request({
@@ -1,36 +0,0 @@
{
"version": 1,
"metadata": {
"model": "zai-glm-5-2",
"tags": [
"prefix:mistral-chat-glm",
"provider:mistral",
"protocol:mistral-chat",
"hosted-model",
"tool",
"tool-call"
],
"name": "mistral-chat-glm/streams-an-indexed-tool-call",
"recordedAt": "2026-08-30T17:38:02.921Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"zai-glm-5-2\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":256,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-8cc4d8f9f07b298a\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"name\":\"\",\"arguments\":\"Paris\\\"}\"},\"index\":0}],\"index\":0,\"content\":\"\"},\"finish_reason\":null,\"logprobs\":null}]}\n\ndata: {\"id\":\"f139bf0e4b984e51aabf6a83c237674d\",\"object\":\"chat.completion.chunk\",\"created\":1788111482,\"model\":\"zai-glm-5-2\",\"choices\":[{\"index\":0,\"delta\":{\"index\":0,\"content\":\"\"},\"finish_reason\":\"stop\",\"logprobs\":null}],\"usage\":{\"prompt_tokens\":171,\"total_tokens\":182,\"completion_tokens\":11,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,47 +0,0 @@
{
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
"name": "mistral-chat/drives-a-tool-loop",
"recordedAt": "2026-08-30T17:18:49.552Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
{
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "text", "usage"],
"name": "mistral-chat/streams-text-with-usage",
"recordedAt": "2026-08-30T17:18:45.432Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.mistral.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"max_tokens\":40,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"9a4d16bdddb74e5e89c2cf9e9b91e065\",\"object\":\"chat.completion.chunk\",\"created\":1788110325,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":22,\"total_tokens\":24,\"completion_tokens\":2,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,24 +0,0 @@
import { LLM } from "../../src/index.js"
import { Mistral } from "../../src/providers.js"
const selected = Mistral.provider.model("mistral-small-latest")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptMode: "reasoning" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { parallelToolCalls: false } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { promptCacheKey: "session-1" } })
LLM.request({
model: selected,
prompt: "Hello",
// @ts-expect-error Mistral reasoning effort must be a string.
providerOptions: { reasoningEffort: 1 },
})
LLM.request({
model: selected,
prompt: "Hello",
// @ts-expect-error Mistral prompt mode only supports reasoning.
providerOptions: { promptMode: "standard" },
})
@@ -74,48 +74,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("omits empty system text while preserving whitespace", () =>
Effect.gen(function* () {
const empty = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: "" }] }))
const whitespace = yield* compileRequest(LLMRequest.update(request, { system: [{ type: "text", text: " " }] }))
expect(empty.body.system).toBeUndefined()
expect(whitespace.body.system).toEqual([{ type: "text", text: " " }])
}),
)
it.effect("filters whitespace-only text and removes empty messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user(" \n\t"),
Message.user([]),
Message.user([
{ type: "text", text: "" },
{ type: "text", text: " Keep this spacing. " },
{ type: "text", text: " \n\t" },
]),
Message.assistant(" \n\t"),
Message.assistant([]),
Message.assistant([{ type: "reasoning", text: "" }]),
Message.assistant([
{ type: "text", text: "" },
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: " Keep this spacing. " }] },
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
])
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { Effect, Ref, Stream } from "effect"
import {
CacheHint,
GenerationOptions,
@@ -84,17 +84,6 @@ const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>)
const fixedBytes = (bytes: Uint8Array) =>
fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
const fixedByteChunks = (...chunks: ReadonlyArray<Uint8Array>) =>
fixedResponse(
new ReadableStream<Uint8Array>({
start(controller) {
chunks.forEach((chunk) => controller.enqueue(chunk))
controller.close()
},
}),
{ headers: { "content-type": "application/vnd.amazon.eventstream" } },
)
const model = AmazonBedrock.configure({
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
@@ -125,50 +114,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("omits empty initial system blocks", () =>
Effect.gen(function* () {
const empty = yield* compileRequest(LLM.request({ model, system: "", prompt: "hello" }))
const cachedEmpty = yield* compileRequest(
LLM.request({
model,
system: [{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "hello",
cache: "none",
}),
)
expect(empty.body.system).toBeUndefined()
expect(cachedEmpty.body.system).toBeUndefined()
}),
)
it.effect("omits empty system blocks while preserving order and cache hints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
system: [
{ type: "text", text: "", cache },
{ type: "text", text: "First." },
{ type: "text", text: " " },
{ type: "text", text: "" },
{ type: "text", text: "Second.", cache },
],
prompt: "hello",
cache: "none",
}),
)
expect(prepared.body.system).toEqual([
{ text: "First." },
{ text: " " },
{ text: "Second." },
{ cachePoint: { type: "default" } },
])
}),
)
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -311,79 +256,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
const input = {
path: "file.ts",
edits: [
{ oldText: "a", newText: "b", "": "" },
null,
true,
7,
"text",
["kept", { "": false, nested: { "": null, value: "ok" } }],
],
nested: { "": "drop", empty: {}, onlyEmpty: { "": 1 } },
" ": "preserve whitespace key",
"": "drop",
}
const original = structuredClone(input)
const call = ToolCallPart.make({ id: "tool_1", name: "edit", input })
const prepared = yield* compileRequest(
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{
toolUse: {
toolUseId: "tool_1",
name: "edit",
input: {
path: "file.ts",
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
nested: { empty: {}, onlyEmpty: {} },
" ": "preserve whitespace key",
},
},
},
],
},
])
expect(input).toEqual(original)
expect(call.input).toBe(input)
}),
)
it.effect("keeps empty tool inputs and empties inputs containing only empty keys", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "tool_empty_key", name: "first", input: { "": { value: true } } }),
ToolCallPart.make({ id: "tool_empty_object", name: "second", input: {} }),
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
],
},
])
}),
)
it.effect("merges parallel tool results into one user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -514,44 +386,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects truncated event-stream frames after message stop", () =>
Effect.gen(function* () {
const partialFrames = [
eventFrame("metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }).subarray(0, 3),
exceptionFrame("modelStreamErrorException", { originalMessage: "Upstream model failed" }).subarray(0, -1),
]
for (const partial of partialFrames) {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(concat([eventFrame("messageStop", { stopReason: "end_turn" }), partial]))),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput", classification: "incomplete-stream" },
message: `Incomplete Bedrock Converse event-stream frame: ${partial.length} buffered bytes remain at end of stream`,
})
expect(error.reason.body).toBe(Encoding.encodeBase64(partial))
}
}),
)
it.effect("decodes frames split across transport chunks through exact-boundary EOF", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedByteChunks(body.subarray(0, 2), body.subarray(2, 17), body.subarray(17))),
)
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("maps model context window exhaustion to length", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
@@ -1040,7 +874,7 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
// Bedrock represents redactedContent blobs as base64 strings on its JSON
// wire. The provider owns the payload and requires byte-exact replay.
const redactedData = "AQID"
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
const response = yield* LLMClient.generate(
LLMRequest.update(baseRequest, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
@@ -1050,8 +884,10 @@ describe("Bedrock Converse route", () => {
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
],
["contentBlockStop", { contentBlockIndex: 0 }],
[
"contentBlockStart",
@@ -1067,17 +903,12 @@ describe("Bedrock Converse route", () => {
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
type: "reasoning-delta",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
})
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: { bedrock: { redactedData } },
})
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -1108,73 +939,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("keeps redacted reasoning accumulation separate by content block index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 2, delta: { reasoningContent: { redactedContent: "Ag==" } } }],
["contentBlockStop", { contentBlockIndex: 2 }],
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "Aw==" } } }],
["contentBlockDelta", { contentBlockIndex: 7, delta: { reasoningContent: { redactedContent: "BA==" } } }],
["contentBlockStop", { contentBlockIndex: 7 }],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQI=" } } },
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AwQ=" } } },
])
}),
)
it.effect("preserves split redacted reasoning when contentBlockStop is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AQ==" } } }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "AgM=" } } }],
["messageStop", { stopReason: "end_turn" }],
),
),
),
)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
])
}),
)
it.effect("rejects invalid redacted reasoning base64 with the triggering event", () =>
Effect.gen(function* () {
const payload = { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: "%%==" } } }
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(fixedBytes(eventStreamBody(["contentBlockDelta", payload]))),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse reasoningContent.redactedContent contains invalid base64 data",
})
expect(JSON.parse(error.reason.body ?? "")).toMatchObject({
headers: { ":event-type": { value: "contentBlockDelta" } },
body: JSON.stringify(payload),
})
expect(error.reason.cause).toBeInstanceOf(Error)
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
@@ -1429,20 +1193,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects image media that is not valid base64", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1566,37 +1316,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects remote media URLs in tool results", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{
type: "file",
uri: "https://example.test/report.pdf",
mime: "application/pdf",
name: "report.pdf",
},
],
},
}),
],
}),
).pipe(Effect.flip)
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
}),
)
it.effect("rejects unsupported image media types", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
-138
View File
@@ -906,94 +906,6 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns unique ids to separated reasoning blocks", () =>
Effect.gen(function* () {
const body = sseEvents(
{
candidates: [
{
content: {
role: "model",
parts: [{ text: "A", thought: true, thoughtSignature: "reasoning_sig_a" }],
},
},
],
},
{
candidates: [
{
content: { role: "model", parts: [{ text: "X", thoughtSignature: "text_sig_x" }] },
},
],
},
{
candidates: [
{
content: {
role: "model",
parts: [{ text: "B", thought: true, thoughtSignature: "reasoning_sig_b" }],
},
},
],
},
{
candidates: [
{
content: { role: "model", parts: [{ text: "Y", thoughtSignature: "text_sig_y" }] },
finishReason: "STOP",
},
],
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
const starts = response.events.filter((event) => event.type === "reasoning-start")
const deltas = response.events.filter((event) => event.type === "reasoning-delta")
const ends = response.events.filter((event) => event.type === "reasoning-end")
expect(starts.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
expect(starts[0]?.id).not.toBe(starts[1]?.id)
expect(deltas.map((event) => ({ id: event.id, text: event.text }))).toEqual([
{ id: "reasoning-0", text: "A" },
{ id: "reasoning-1", text: "B" },
])
expect(ends.map((event) => event.id)).toEqual(["reasoning-0", "reasoning-1"])
expect(response.events.filter((event) => event.type === "text-start").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.events.filter((event) => event.type === "text-delta").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.events.filter((event) => event.type === "text-end").map((event) => event.id)).toEqual([
"text-0",
"text-1",
])
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "A",
providerMetadata: { google: { thoughtSignature: "reasoning_sig_a" } },
},
{
type: "text",
text: "X",
providerMetadata: { google: { thoughtSignature: "text_sig_x" } },
},
{
type: "reasoning",
text: "B",
providerMetadata: { google: { thoughtSignature: "reasoning_sig_b" } },
},
{
type: "text",
text: "Y",
providerMetadata: { google: { thoughtSignature: "text_sig_y" } },
},
])
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -1368,56 +1280,6 @@ describe("Gemini route", () => {
}),
)
it.effect("separates text blocks around streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "before" },
{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } },
{ text: "after" },
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.slice(1, 8)).toEqual([
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "before" },
{ type: "text-end", id: "text-0" },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{ type: "text-start", id: "text-1" },
{ type: "text-delta", id: "text-1", text: "after" },
{ type: "text-end", id: "text-1" },
])
const textStarts = response.events.filter((event) => event.type === "text-start")
expect(textStarts[0]?.id).not.toBe(textStarts[1]?.id)
expect(response.message.content).toEqual([
{ type: "text", text: "before" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
{ type: "text", text: "after" },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "STOP" })
}),
)
it.effect("defaults omitted function call args to an empty object", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -1,694 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = Mistral.configure({ apiKey: "fixture" }).model("mistral-large-latest")
const request = LLM.request({ model, prompt: "Hello" })
const chunk = (delta: object, finishReason: string | null = null, usage?: object) => ({
choices: [{ delta, finish_reason: finishReason }],
usage,
})
describe("Mistral Chat", () => {
test("exposes native provider and protocol identities", async () => {
const entrypoint = await import("@opencode-ai/ai/providers/mistral")
expect(Mistral.id).toBe("mistral")
expect(MistralChat.protocol.id).toBe("mistral-chat")
expect(Mistral.route).toMatchObject({
id: "mistral-chat",
provider: "mistral",
providerMetadataKey: "mistral",
protocol: "mistral-chat",
})
expect(Mistral.route.endpoint).toMatchObject({
baseURL: "https://api.mistral.ai/v1",
path: "/chat/completions",
})
expect(entrypoint.model).toBeFunction()
})
it.effect("lowers native messages, media, tool choice, options, and replay IDs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial",
messages: [
Message.system("Updated"),
Message.user([
{ type: "text", text: "Inspect" },
{ type: "media", mediaType: "image/png", data: "aW1hZ2U=" },
{ type: "media", mediaType: "application/pdf", data: "cGRm" },
]),
Message.assistant([
{ type: "reasoning", text: "Think" },
{ type: "text", text: "Calling" },
{ type: "tool-call", id: "call.same-prefix-1", name: "lookup", input: { city: "Paris" } },
{ type: "tool-call", id: "call.same-prefix-2", name: "other", input: {} },
]),
Message.tool({ id: "call.same-prefix-1", name: "lookup", result: { ok: true } }),
],
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
],
toolChoice: "lookup",
promptCacheKey: "session-1",
generation: {
maxTokens: 64,
seed: 7,
temperature: 0.2,
topP: 0.8,
frequencyPenalty: 0.1,
presencePenalty: 0.3,
stop: ["done"],
},
providerOptions: {
safePrompt: true,
documentImageLimit: 3,
documentPageLimit: 8,
parallelToolCalls: false,
reasoningEffort: "high",
},
}),
)
expect(prepared.body).toMatchObject({
model: "mistral-large-latest",
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
tool_choice: { type: "function", function: { name: "lookup" } },
stream: true,
max_tokens: 64,
random_seed: 7,
temperature: 0.2,
top_p: 0.8,
frequency_penalty: 0.1,
presence_penalty: 0.3,
stop: ["done"],
prompt_cache_key: "session-1",
safe_prompt: true,
document_image_limit: 3,
document_page_limit: 8,
parallel_tool_calls: false,
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
{
role: "user",
content: [
{ type: "text", text: "Inspect" },
{ type: "image_url", image_url: "data:image/png;base64,aW1hZ2U=" },
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
],
},
{
role: "assistant",
content: "ThinkCalling",
},
])
const assistant = prepared.body.messages[3]
const toolResult = prepared.body.messages[4]
expect(assistant?.role).toBe("assistant")
expect(toolResult?.role).toBe("tool")
if (assistant?.role !== "assistant" || toolResult?.role !== "tool") return
const ids = assistant.tool_calls?.map((tool) => tool.id) ?? []
expect(ids).toHaveLength(2)
expect(ids[0]).toMatch(/^[A-Za-z0-9]{9}$/)
expect(ids[1]).toMatch(/^[A-Za-z0-9]{9}$/)
expect(ids[0]).not.toBe(ids[1])
expect(toolResult.tool_call_id).toBe(ids[0])
expect(toolResult.name).toBe("lookup")
}),
)
it.effect("preserves valid replay IDs", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
Message.tool({ id: "Ab12Cd34E", name: "lookup", result: "ok" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ tool_calls: [{ id: "Ab12Cd34E" }] },
{ tool_call_id: "Ab12Cd34E" },
])
}),
)
it.effect("applies trailing prefix, cache, and reasoning options without changing earlier assistants", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
promptCacheKey: "common-key",
messages: [Message.assistant("Earlier"), Message.user("Continue"), Message.assistant("Prefix")],
providerOptions: { promptCacheKey: "native-key", promptMode: "reasoning" },
}),
)
expect(prepared.body.prompt_cache_key).toBe("native-key")
expect(prepared.body.prompt_mode).toBe("reasoning")
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Earlier" },
{ role: "user", content: "Continue" },
{ role: "assistant", content: "Prefix", prefix: true },
])
const uncached = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "common-key",
cache: "none",
providerOptions: { promptCacheKey: "native-key" },
}),
)
expect(uncached.body.prompt_cache_key).toBeUndefined()
const longKey = "cache-key-".repeat(10)
const unbounded = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: longKey,
}),
)
expect(unbounded.body.prompt_cache_key).toBe(longKey)
const conflict = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
providerOptions: { reasoningEffort: "high", promptMode: "reasoning" },
}),
).pipe(Effect.flip)
expect(conflict.message).toContain("mutually exclusive")
}),
)
it.effect("omits empty assistant history unless it carries a tool call", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant(" \n "),
Message.assistant({ type: "reasoning", text: "\t" }),
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: "",
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
},
])
}),
)
it.effect("preserves remote media URLs and structured tool-result media", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://assets.example.test/input.png",
}),
Message.tool({
id: "Ab12Cd34E",
name: "inspect",
resultType: "content",
result: [
{ type: "text", text: "Result" },
{ type: "file", mime: "image/jpeg", uri: "https://assets.example.test/output.jpg" },
{ type: "file", mime: "application/pdf", uri: "cGRm" },
],
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ type: "image_url", image_url: "https://assets.example.test/input.png" }],
},
{
role: "tool",
tool_call_id: "Ab12Cd34E",
name: "inspect",
content: [
{ type: "text", text: "Result" },
{ type: "image_url", image_url: "https://assets.example.test/output.jpg" },
{ type: "document_url", document_url: "data:application/pdf;base64,cGRm" },
],
},
])
}),
)
it.effect("concatenates text-only user and tool content without separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "first" },
{ type: "text", text: "second" },
]),
Message.tool({
id: "Ab12Cd34E",
name: "lookup",
resultType: "content",
result: [
{ type: "text", text: "third" },
{ type: "text", text: "fourth" },
],
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "firstsecond" },
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "thirdfourth" },
])
}),
)
it.effect("streams ordered thinking and text and replays native thinking metadata", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
chunk({ content: [{ type: "text", text: "Answer" }] }),
chunk({}, "stop"),
),
),
),
)
expect(response.reasoning).toBe("Consider")
expect(response.text).toBe("Answer")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Consider",
providerMetadata: {
mistral: {
thinking: {
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
marker: "empty",
},
},
},
},
{ type: "text", text: "Answer" },
])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
marker: "empty",
},
{ type: "text", text: "Answer" },
],
prefix: true,
},
])
}),
)
it.effect("replays metadata-only native thinking", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(chunk({ content: [{ type: "thinking", thinking: [], marker: "opaque" }] }), chunk({}, "stop")),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "",
providerMetadata: {
mistral: { thinking: { type: "thinking", thinking: [], marker: "opaque" } },
},
},
])
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: [], marker: "opaque" }],
prefix: true,
},
])
}),
)
it.effect("merges indexed argument fragments with missing continuation identity", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
}),
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: '"Paris"}' } }] }),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content).toContainEqual({
type: "tool-call",
id: "Ab12Cd34E",
name: "lookup",
input: { city: "Paris" },
})
expect(
response.events.filter(
(event) =>
LLMEvent.is.toolInputStart(event) ||
LLMEvent.is.toolInputDelta(event) ||
LLMEvent.is.toolInputEnd(event) ||
LLMEvent.is.toolCall(event),
),
).toEqual([
{ type: "tool-input-start", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
{
type: "tool-input-delta",
id: "Ab12Cd34E",
name: "lookup",
text: '{"city":',
input: {},
},
{
type: "tool-input-delta",
id: "Ab12Cd34E",
name: "lookup",
text: '"Paris"}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "Ab12Cd34E",
name: "lookup",
input: { city: "Paris" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("normalizes stop to tool calls when a hosted model emits indexed tool fragments", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{
index: 0,
id: "chatcmpl-tool-8cc4d8f9f07b298a",
function: { name: "lookup", arguments: '{"city":"' },
},
],
}),
chunk({ tool_calls: [{ index: 0, function: { name: "", arguments: 'Paris"}' } }] }),
chunk({}, "stop"),
),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "stop" })
expect(response.toolCalls).toMatchObject([{ name: "lookup", input: { city: "Paris" } }])
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("generates a stable ID when the first indexed fragment has null identity", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: null, function: { name: "lookup", arguments: { city: "Paris" } } }],
}),
chunk({}, "tool_calls"),
),
),
),
)
const call = response.message.content.find((part) => part.type === "tool-call")
expect(call?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(call).toMatchObject({ name: "lookup", input: { city: "Paris" } })
}),
)
it.effect("generates distinct IDs for parallel null and literal-null identities", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ index: 0, id: null, function: { name: "first", arguments: {} } },
{ index: 1, id: "null", function: { name: "second", arguments: {} } },
],
}),
chunk({}, "tool_calls"),
),
),
),
)
const calls = response.message.content.filter((part) => part.type === "tool-call")
expect(calls).toHaveLength(2)
expect(calls[0]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(calls[1]?.id).toMatch(/^[A-Za-z0-9]{9}$/)
expect(calls[0]?.id).not.toBe(calls[1]?.id)
}),
)
it.effect("keeps parallel indexed calls independent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ index: 0, id: "Ab12Cd34E", function: { name: "first", arguments: '{"n":' } },
{ index: 1, id: "Fg56Hi78J", function: { name: "second", arguments: '{"n":' } },
],
}),
chunk({
tool_calls: [
{ index: 0, function: { arguments: "1}" } },
{ index: 1, function: { arguments: "2}" } },
],
}),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content.filter((part) => part.type === "tool-call")).toEqual([
{ type: "tool-call", id: "Ab12Cd34E", name: "first", input: { n: 1 } },
{ type: "tool-call", id: "Fg56Hi78J", name: "second", input: { n: 2 } },
])
}),
)
it.effect("correlates parallel identity-less fragments by batch position", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [
{ function: { name: "first", arguments: '{"n":' } },
{ function: { name: "second", arguments: '{"n":' } },
],
}),
chunk({
tool_calls: [{ function: { arguments: "1}" } }, { function: { arguments: "2}" } }],
}),
chunk({}, "tool_calls"),
),
),
),
)
expect(response.message.content.filter((part) => part.type === "tool-call")).toMatchObject([
{ name: "first", input: { n: 1 } },
{ name: "second", input: { n: 2 } },
])
}),
)
it.effect("maps usage variants and clamps cache reads", () =>
Effect.gen(function* () {
for (const usage of [
{ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7, num_cached_tokens: 9 },
{ prompt_tokens: 5, completion_tokens: 2, prompt_token_details: { cached_tokens: 2 } },
{ prompt_tokens: 5, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 3 } },
]) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({}, "stop", usage)))),
)
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
totalTokens: 7,
})
expect(response.usage?.cacheReadInputTokens).toBe(
Math.min(
5,
usage.num_cached_tokens ??
usage.prompt_token_details?.cached_tokens ??
usage.prompt_tokens_details?.cached_tokens ??
0,
),
)
}
}),
)
it.effect("maps finish reasons and does not finalize truncated tool calls", () =>
Effect.gen(function* () {
for (const [raw, normalized] of [
["stop", "stop"],
["model_length", "length"],
["tool_calls", "tool-calls"],
["error", "error"],
["future_reason", "unknown"],
] as const) {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({}, raw)))),
)
expect(response.finishReason).toEqual({ normalized, raw })
}
const truncated = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({
tool_calls: [{ index: 0, id: "Ab12Cd34E", function: { name: "lookup", arguments: '{"city":' } }],
}),
chunk({}, "length"),
),
),
),
)
expect(truncated.finishReason).toEqual({ normalized: "length", raw: "length" })
expect(truncated.events.some(LLMEvent.is.toolCall)).toBe(false)
expect(truncated.events.some(LLMEvent.is.toolInputEnd)).toBe(false)
}),
)
it.effect("ignores non-text output parts and rejects invalid stream endings", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
chunk({ content: null }),
chunk({
content: [
{ type: "reference", reference_ids: [1] },
{ type: "image_url", image_url: "https://example.test/image.png" },
{ type: "text", text: "Answer" },
],
}),
chunk({}, "stop"),
),
),
),
)
expect(response.text).toBe("Answer")
const missingFinish = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(chunk({ content: "partial" })))),
Effect.flip,
)
expect(missingFinish.message).toContain("without finish_reason")
const lateContent = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents(chunk({}, "stop"), chunk({ content: [{ type: "text", text: "late" }] }))),
),
Effect.flip,
)
expect(lateContent.message).toContain("content after the finish reason")
}),
)
it.effect("uses environment bearer auth and custom package settings", () =>
LLMClient.generate(
LLM.request({
model: Mistral.model("fixture-model", {
baseURL: "https://mistral.test/v1",
headers: { "x-app": "test" },
body: { service_tier: "priority" },
providerOptions: { safePrompt: true },
}),
prompt: "Hello",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://mistral.test/v1/chat/completions")
expect(web.headers.get("authorization")).toBe("Bearer secret")
expect(web.headers.get("x-app")).toBe("test")
expect(input.text).toContain('"service_tier":"priority"')
return input.respond(sseEvents(chunk({}, "stop")), { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MISTRAL_API_KEY: "secret" } }))),
),
)
})
@@ -1,159 +0,0 @@
import { configure } from "@opencode-ai/ai/providers/mistral"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.MISTRAL_API_KEY ?? "fixture"
const recorded = recordedTests({
prefix: "mistral-chat",
provider: "mistral",
protocol: "mistral-chat",
requires: ["MISTRAL_API_KEY"],
})
const glmRecorded = recordedTests({
prefix: "mistral-chat-glm",
provider: "mistral",
protocol: "mistral-chat",
requires: ["MISTRAL_API_KEY"],
})
const weather = ToolDefinition.make({
name: "lookup_weather",
description: "Look up the current weather for a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
describe("Mistral recorded", () => {
recorded.effect.with(
"streams text with usage",
{ tags: ["text", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest"),
prompt: "Reply with exactly one word: hello",
generation: { maxTokens: 40, temperature: 0 },
}),
)
expect(response.text.trim()).toMatch(/^(?:hello|hi)[!.]?$/i)
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
60_000,
)
recorded.effect.with(
"replays native reasoning",
{ tags: ["reasoning", "replay", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "high" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
prompt: "Calculate 17 multiplied by 23. Think briefly, then reply with only the integer.",
generation: { maxTokens: 512, temperature: 0 },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.text.trim()).toBe("391")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const followUp = LLMRequest.update(firstRequest, {
messages: [...firstRequest.messages, first.message, Message.user("Reply with exactly: Done.")],
generation: { maxTokens: 256, temperature: 0 },
})
const replay = yield* compileRequest(followUp)
expect(replay.body.messages).toContainEqual(
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining([expect.objectContaining({ type: "thinking" })]),
}),
)
const second = yield* LLMClient.generate(followUp)
expect(second.text.trim()).toMatch(/Done\.?$/)
expect(second.finishReason.normalized).toBe("stop")
}),
60_000,
)
recorded.effect.with(
"drives a tool loop",
{ tags: ["tool", "tool-loop", "usage"], metadata: { model: "mistral-small-latest" } },
() =>
Effect.gen(function* () {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
generation: { maxTokens: 160, temperature: 0 },
})
const first = yield* LLMClient.generate(firstRequest)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
const call = first.toolCalls[0]
if (!call) throw new Error("Mistral did not return a tool call")
const followUp = LLMRequest.update(firstRequest, {
toolChoice: ToolChoice.make("none"),
messages: [
...firstRequest.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
],
generation: { maxTokens: 160, temperature: 0 },
})
const second = yield* LLMClient.generate(followUp)
expect(second.finishReason.normalized).toBe("stop")
expect(second.toolCalls).toHaveLength(0)
expect(second.text.toLowerCase()).toContain("sunny")
}),
60_000,
)
})
describe("Mistral hosted GLM recorded", () => {
glmRecorded.effect.with(
"streams an indexed tool call",
{ tags: ["hosted-model", "tool", "tool-call"], metadata: { model: "zai-glm-5-2" } },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: configure({ apiKey }).model("zai-glm-5-2"),
system: "Call lookup_weather exactly once with Paris.",
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
generation: { maxTokens: 256, temperature: 0 },
}),
)
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.toolCalls).toMatchObject([{ name: "lookup_weather", input: { city: "Paris" } }])
expect(response.events.filter(LLMEvent.is.toolInputStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolInputDelta).length).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
60_000,
)
})
@@ -96,27 +96,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("omits user messages with no content", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Before."), Message.user([]), Message.user("After.")],
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
Effect.gen(function* () {
const model = configure({
-77
View File
@@ -1,77 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolCallPart, ToolResultPart } from "../src/schema/messages.js"
import { normalizeToolHistory } from "../src/tool-history.js"
const toolCall = (id: string, name = id) => ToolCallPart.make({ id, name, input: {} })
const toolResult = (id: string, value: unknown, name = id, resultType?: "text" | "content" | "error") =>
Message.tool(ToolResultPart.make({ id, name, result: value, resultType }))
describe("tool history normalization", () => {
test("fills missing local results before the next step", () => {
const normalized = normalizeToolHistory([
Message.assistant([toolCall("first"), toolCall("second")]),
toolResult("first", "done", "wrong", "text"),
Message.user("Continue."),
Message.assistant(toolCall("trailing")),
])
expect(normalized.map((message) => message.role)).toEqual([
"assistant",
"tool",
"tool",
"user",
"assistant",
])
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
expect(normalized[2]?.content).toEqual([
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
])
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
})
test("normalizes empty results without changing whitespace or media", () => {
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
const normalized = normalizeToolHistory([
Message.assistant([
toolCall("text"),
toolCall("content"),
toolCall("error"),
toolCall("mixed"),
toolCall("whitespace"),
]),
toolResult("text", "", "text", "text"),
toolResult("content", [], "content", "content"),
toolResult("error", "", "error", "error"),
toolResult("mixed", [{ type: "text", text: "" }, media], "mixed", "content"),
toolResult("whitespace", " ", "whitespace", "text"),
])
expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
])
})
test("leaves unmatched and provider-executed history unchanged", () => {
const hostedCall = ToolCallPart.make({
id: "hosted",
name: "web_search",
input: {},
providerExecuted: true,
})
const hostedResult = ToolResultPart.make({
id: "hosted",
name: "web_search",
result: "",
resultType: "text",
providerExecuted: true,
})
const hosted = Message.assistant([hostedCall, hostedResult])
const orphan = toolResult("orphan", "ignored", "orphan", "text")
expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted])
})
})
@@ -1,34 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
const text = await input.textContent()
for (let count = 0; count < 2; count++) {
await input.press("ControlOrMeta+a")
expect(
await input.evaluate((editor) => {
const selection = window.getSelection()
return {
text: selection?.toString(),
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
}
}),
).toEqual({ text, inside: true })
}
await page.keyboard.type("Replacement draft")
await expect(input).toHaveText("Replacement draft")
await expect(component.getByRole("status")).toHaveText("Ready")
if (draft === "mixed-attachments") {
await expect(component.getByAltText("layout.png")).toBeVisible()
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
}
})
}
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
@@ -1,4 +1,4 @@
import { expect, test, type Locator } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -65,64 +65,11 @@ for (const lines of [6000, 25000]) {
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
expect(await events.evaluate((events) => events.count)).toBe(1)
await expect(input).toBeFocused()
await expectCaretVisible(input)
const scroll = page.locator('[data-component="composer-scroll"]')
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
await page.keyboard.type("!")
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
await expectCaretVisible(input)
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
const bounds = await scroll.boundingBox()
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
await page.mouse.down()
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
await page.mouse.up()
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
await expect(input).toBeFocused()
await page.keyboard.press("ControlOrMeta+Home")
await page.keyboard.press("ControlOrMeta+End")
await expectCaretVisible(input)
})
}
async function expectCaretVisible(input: Locator) {
await expect
.poll(() =>
input.evaluate((element) => {
const selection = window.getSelection()
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
const caret = selection.getRangeAt(0).getBoundingClientRect()
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
}),
)
.toBe(true)
}
for (const width of [390, 1280]) {
for (const direction of ["ltr", "rtl"]) {
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 })
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
const suffix = "\nExisting trailing content".repeat(100)
await input.fill("Before " + suffix)
await input.press("ControlOrMeta+Home")
await input.press("ArrowRight")
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
await page.keyboard.press("ControlOrMeta+V")
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
await expectCaretVisible(input)
await page.keyboard.type("!")
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
await expectCaretVisible(input)
})
}
}
for (const text of [
"single line <b> &amp;",
"first\nsecond",
@@ -65,8 +65,8 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
status: "active",
tui: false,
})),
},
})
@@ -83,12 +83,7 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
json: {
location: { directory },
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
],
},
})
+4 -26
View File
@@ -9,7 +9,6 @@ import { Button } from "@opencode-ai/ui/button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
import { typeLabel } from "@opencode-ai/session-ui/message-file"
@@ -54,7 +53,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
const state = props.controller.state
const view = props.controller.view
let editor: HTMLDivElement | undefined
let viewport: HTMLDivElement | undefined
let localInput = false
const updateCursor = () => {
if (!editor || !window.getSelection()?.isCollapsed) return
@@ -147,14 +145,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
</Show>
<ScrollView
data-component="composer-scroll"
class="min-h-[60px] max-h-[180px]"
viewportRef={(element) => {
viewport = element
element.tabIndex = -1
}}
>
<div class="relative min-h-[60px]">
<div
ref={(element) => {
editor = element
@@ -171,7 +162,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
style={{
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
@@ -199,20 +190,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={(event) => {
props.controller.onPaste(event)
// Programmatic multiline insertion does not reliably reveal the caret.
requestAnimationFrame(() => {
const selection = window.getSelection()
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
if (!editor.contains(selection.anchorNode)) return
const caret = selection.getRangeAt(0).getBoundingClientRect()
if (!caret.height) return
const bounds = viewport.getBoundingClientRect()
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
})
}}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<Show when={!props.controller.value()}>
@@ -228,7 +206,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
</div>
</Show>
</ScrollView>
</div>
<div class="flex h-11 items-center px-2">
<div
@@ -5,20 +5,10 @@ import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
+1 -1
View File
@@ -298,7 +298,7 @@ export function Titlebar(props: {
id: "home.toggle",
title: language.t("home.title"),
category: language.t("command.category.view"),
keybind: windows() ? "alt+home" : "mod+b",
keybind: "mod+b",
hidden: true,
onSelect: toggleHome,
},
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "@/shell/commands/desktop-menu"
import { windowsMenuAccelerator } from "./windows-menu"
describe("Windows app menu", () => {
@@ -9,24 +8,7 @@ describe("Windows app menu", () => {
)
})
test("leaves select all to the focused browser editor", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "a", ctrlKey: true }))).toBeUndefined()
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "A", ctrlKey: true }))).toBeUndefined()
})
test("ignores the accelerator without its modifiers", () => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key: "N" }))).toBeUndefined()
})
test.each(["v", "c", "x", "a", "z", "y"])("leaves Ctrl+%s to the focused editor", (key) => {
expect(windowsMenuAccelerator(new KeyboardEvent("keydown", { key, ctrlKey: true }))).toBeUndefined()
})
test("preserves the paste menu action and shortcut label", () => {
expect(
DESKTOP_MENU.flatMap((menu) => menu.items ?? []).find(
(entry) => entry.type === "item" && entry.action === "edit.paste",
),
).toMatchObject({ action: "edit.paste", accelerator: { windows: "Ctrl+V" } })
})
})
@@ -16,8 +16,6 @@ import { useLanguage } from "@/runtime/i18n/language"
const accelerators = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).flatMap((entry) => {
if (entry.type === "separator" || !entry.action || !entry.accelerator?.windows) return []
// Let the focused editor handle editing shortcuts without restoring stale menu focus.
if (entry.action.startsWith("edit.")) return []
return [{ action: entry.action, keybind: parseKeybind(entry.accelerator.windows) }]
})
@@ -1,5 +1,4 @@
import { EOL } from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
@@ -8,7 +7,7 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -20,7 +19,7 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
@@ -49,15 +48,11 @@ export function format(
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
.map((plugin) => `${name(plugin)} (${plugin.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
+2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+16 -2
View File
@@ -486,7 +486,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
})
}),
)
@@ -494,7 +501,14 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
+2
View File
@@ -9,6 +9,7 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -145,6 +146,7 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
+7 -25
View File
@@ -6,23 +6,18 @@ test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
status: "active",
tui: true,
},
{
source: { type: "package", package: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
{
id: "local.dual",
source: { type: "local", path: "/tmp/local/index.ts" },
state: { status: "active" },
features: { server: true, tui: true },
status: "failed",
error: "broken",
tui: false,
},
],
[
@@ -33,7 +28,6 @@ test("formats server and TUI plugins in sections without builtins", () => {
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
@@ -41,24 +35,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format(
[
{
id: "opencode.agent",
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
],
[],
true,
),
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
+1 -2
View File
@@ -55,7 +55,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
"zod": "catalog:"
"solid-js": "catalog:"
}
}
-2
View File
@@ -1,7 +1,5 @@
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
export type { RpcApi, RpcClient } from "./rpc.js"
export type * from "./api/api.js"
export type WebSearchApi<E = never> = WebsearchApi<E>
-14
View File
@@ -1573,19 +1573,6 @@ export interface SkillApi<E = never> {
readonly list: SkillListOperation<E>
}
export type RpcCallInput = {
readonly rpcID: string
readonly method: string
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly input?: unknown | undefined
}
export type RpcCallOutput = { readonly output?: unknown }
export type RpcCallOperation<E = never> = (input: RpcCallInput) => Effect.Effect<RpcCallOutput, E>
export interface RpcApi<E = never> {
readonly call: RpcCallOperation<E>
}
export type EventSubscribeOutput = OpenCodeEvent
export type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>
@@ -2086,7 +2073,6 @@ export interface AppApi<E = never> {
readonly file: FileApi<E>
readonly command: CommandApi<E>
readonly skill: SkillApi<E>
readonly rpc: RpcApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
-58
View File
@@ -1,58 +0,0 @@
export * as OpenCode from "./client.js"
import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
import { ClientError, OpenCode } from "./generated/index.js"
import { RpcClientRuntime } from "./rpc.js"
import type { RpcCallOptions } from "../promise/rpc.js"
const CurrentHeaders = Context.Reference<RpcCallOptions["headers"]>("@opencode-ai/client/effect/rpc/headers", {
defaultValue: () => undefined,
})
export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) {
const httpClient = yield* HttpClient.HttpClient
const raw = yield* OpenCode.make(options).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
),
),
)
const context = yield* Effect.context()
const native = raw.event.subscribe()
// Async iterators throw a squashed cause; retain the native typed failures and defects intact.
class EventFailure {
constructor(readonly cause: Cause.Cause<Stream.Error<typeof native>>) {}
}
const shared = SharedEvents.make((signal) =>
Stream.toAsyncIterableWith(
native.pipe(
Stream.interruptWhen(RpcClientRuntime.aborted(signal)),
Stream.catchCause((cause) => Stream.fail(new EventFailure(cause))),
),
context,
),
)
const subscribe = () =>
Stream.fromAsyncIterable(shared.subscribe(), (error) => error).pipe(
Stream.catch((error) =>
Stream.failCause(error instanceof EventFailure ? error.cause : Cause.fail(new ClientError({ cause: error }))),
),
)
return {
...raw,
event: { ...raw.event, subscribe },
rpc: Object.assign(
RpcClientRuntime.make(
(input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)),
subscribe,
),
raw.rpc,
),
}
})
@@ -185,8 +185,6 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1168,17 +1166,6 @@ const EndpointSkillList = (raw: RawClient["server.skill"]) => (input?: SkillList
const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSkillList(raw) })
const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) =>
preserveEffect<RpcCallOutput>()(
raw["rpc.call"]({
params: { rpcID: input["rpcID"], method: input["method"] },
query: { location: input["location"] },
payload: { input: input["input"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupRpc = (raw: RawClient["server.rpc"]) => ({ call: EndpointRpcCall(raw) })
const EndpointEventSubscribe = (raw: RawClient["server.event"]) => () =>
preserveStream<EventSubscribeOutput>()(
Stream.unwrap(
@@ -1577,7 +1564,6 @@ const adaptClient = (raw: RawClient) => ({
file: adaptGroupFile(raw["server.fs"]),
command: adaptGroupCommand(raw["server.command"]),
skill: adaptGroupSkill(raw["server.skill"]),
rpc: adaptGroupRpc(raw["server.rpc"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
+1 -5
View File
@@ -1,10 +1,8 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
import type { OpenCode } from "./client.js"
export * from "./generated/index"
export { OpenCode } from "./client.js"
export type {
AgentApi,
AppApi,
@@ -17,8 +15,6 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcClient,
WebSearchApi,
SessionApi,
SkillApi,
@@ -52,4 +48,4 @@ export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof OpenCode.make>>
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
-94
View File
@@ -1,94 +0,0 @@
export * as RpcClientRuntime from "./rpc.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Effect, Schema, Stream } from "effect"
import type { RpcArguments, RpcCallOptions } from "../promise/rpc.js"
import { RpcRuntime } from "../rpc-runtime.js"
import type { RpcCallInput, RpcCallOutput } from "./api/api.js"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
type DecodeError<S> = S extends Schema.Top ? Schema.SchemaError : never
export type RpcClient<
D extends Rpc.Definition,
E = never,
Options = RpcCallOptions,
EventError = E,
> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Effect.Effect<
Rpc.Output<D["methods"][Name]["output"]>,
Rpc.MethodError<D["methods"][Name]> | DecodeError<D["methods"][Name]["output"]> | E
>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
) => Stream.Stream<Rpc.EventPayload<D, Name>, DecodeError<D["events"][Name]["schema"]> | EventError>
}
}
export interface RpcApi<E = never, Options = RpcCallOptions, EventError = E> {
<D extends Rpc.Definition>(definition: D): RpcClient<D, E, Options, EventError>
}
export function make<CallError, EventError>(
call: (input: RpcCallInput, options?: RpcCallOptions) => Effect.Effect<RpcCallOutput, CallError>,
subscribe: () => Stream.Stream<OpenCodeEvent, EventError>,
): RpcApi<Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError> {
return <D extends Rpc.Definition>(definition: D) => {
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input?: unknown, options?: RpcCallOptions) => {
const result = Effect.gen(function* () {
const response = yield* call(
{
rpcID: definition.id,
method: name,
input,
location: options?.location,
},
options,
)
return yield* RpcRuntime.read(method.output, response.output)
}).pipe(Effect.catch((error) => RpcRuntime.readError(method, error)))
const signal = options?.signal
if (!signal) return result
return Effect.suspend(() =>
signal.aborted
? Effect.interrupt
: Effect.raceFirst(result, Effect.andThen(aborted(signal), Effect.interrupt)),
)
},
]),
)
// SAFETY: Every runtime key comes from this definition, and each value is decoded through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(methods, {
events: {
subscribe: (name: keyof D["events"] & string) => {
const type = RpcRuntime.eventType(definition, name)
if (!Object.hasOwn(definition.events, name)) return Stream.fail(new Error(`Unknown RPC event: ${type}`))
const schema = definition.events[name]
return subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === type),
Stream.mapEffect((event) => RpcRuntime.event(definition, name, schema, event)),
)
},
},
}) as RpcClient<D, Exclude<CallError, RpcError | RpcInternalError> | Rpc.SystemError, RpcCallOptions, EventError>
}
}
export function aborted(signal: AbortSignal) {
return Effect.callback<void>((resume) => {
if (signal.aborted) return resume(Effect.void)
const abort = () => resume(Effect.void)
signal.addEventListener("abort", abort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", abort))
})
}
+1 -5
View File
@@ -1,8 +1,4 @@
import type { OpenCode } from "./client.js"
type Client = ReturnType<typeof OpenCode.make>
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
type Client = ReturnType<typeof import("./generated/client.js").make>
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
-18
View File
@@ -1,18 +0,0 @@
export * as OpenCode from "./client.js"
import { SharedEvents } from "../shared-events.js"
import { OpenCode } from "./generated/index.js"
import type { ClientOptions } from "./generated/client.js"
import { makeRpc } from "./rpc.js"
export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const events = SharedEvents.make((signal) => raw.event.subscribe({ signal }))
return {
...raw,
rpc: Object.assign(makeRpc(raw, events), raw.rpc),
event: events,
}
}
@@ -181,8 +181,6 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
@@ -1596,21 +1594,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
rpc: {
call: (input: RpcCallInput, requestOptions?: RequestOptions) =>
request<RpcCallOutput>(
{
method: "POST",
path: `/api/rpc/${encodeURIComponent(input.rpcID)}/${encodeURIComponent(input.method)}`,
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 500, 401],
empty: false,
},
requestOptions,
),
},
event: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventSubscribeOutput> =>
sse<EventSubscribeOutput>(
+3 -46
View File
@@ -16,10 +16,6 @@ export type PluginSource =
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
export type MoneyUSD = number
@@ -337,8 +333,6 @@ export type SkillInfo = {
content: string
}
export type RpcOutput = { output?: any }
export type PermissionReply = "once" | "always" | "reject"
export type Pty = {
@@ -448,7 +442,9 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo = { id?: string; source: PluginSource; features: PluginFeatures; state: PluginState }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type SessionMessageLocationSwitched = {
id: string
@@ -463,15 +459,6 @@ export type SessionMessageLocationSwitched = {
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type V2EventRpc = {
id: string
created: number
metadata?: { [x: string]: any } | undefined
type: `${"rpc."}${string}`
location: LocationRef
data: { [x: string]: any }
}
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: any } | undefined
@@ -2328,7 +2315,6 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| V2EventRpc
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
@@ -2495,24 +2481,6 @@ export type PermissionNotFoundError = {
export const isPermissionNotFoundError = (value: unknown): value is PermissionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PermissionNotFoundError"
export type RpcError = {
readonly _tag: "RpcError"
readonly type: string
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcError = (value: unknown): value is RpcError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcError"
export type RpcInternalError = {
readonly _tag: "RpcInternalError"
readonly type: "rpc.internal" | "rpc.invalid_output"
readonly message: string
readonly data?: unknown | undefined
}
export const isRpcInternalError = (value: unknown): value is RpcInternalError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "RpcInternalError"
export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly ptyID: string; readonly message: string }
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
@@ -5701,17 +5669,6 @@ export type SkillListOutput = {
data: Array<SkillInfo>
}
export type RpcCallInput = {
readonly rpcID: { readonly rpcID: string; readonly method: string }["rpcID"]
readonly method: { readonly rpcID: string; readonly method: string }["method"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly input?: { readonly input: JsonValue }["input"]
}
export type RpcCallOutput = RpcOutput
export type EventSubscribeOutput = V2Event
export type PtyListInput = {
+1 -8
View File
@@ -1,7 +1,4 @@
import type { OpenCode } from "./client.js"
export * from "./generated/index.js"
export { OpenCode } from "./client.js"
export type {
AgentApi,
CatalogApi,
@@ -13,13 +10,9 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcCallOptions,
RpcClient,
RpcEventPayload,
WebSearchApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types.js"
export type OpenCodeClient = ReturnType<typeof OpenCode.make>
export type OpenCodeClient = ReturnType<typeof import("./generated/client.js").make>
-147
View File
@@ -1,147 +0,0 @@
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { make, RequestOptions } from "./generated/client.js"
import { isRpcError, isRpcInternalError } from "./generated/types.js"
import type { EventSubscribeOutput, LocationGetInput, RpcCallInput } from "./generated/types.js"
type RpcEvent = Extract<EventSubscribeOutput, { type: `rpc.${string}` }>
export interface RpcCallOptions extends RequestOptions {
readonly location?: LocationGetInput["location"]
}
export type RpcArguments<Input, Options> = unknown extends Input
? [input: Input, options?: Options]
: undefined extends Input
? [input?: Input, options?: Options]
: [input: Input, options?: Options]
export type RpcClient<D extends Rpc.PortableDefinition, Options = RpcCallOptions> = {
readonly [Name in keyof D["methods"]]: (
...args: RpcArguments<Rpc.Input<D["methods"][Name]["input"]>, Options>
) => Promise<Rpc.Output<D["methods"][Name]["output"]>>
} & {
readonly events: {
readonly subscribe: <Name extends keyof D["events"] & string>(
name: Name,
options?: Pick<RequestOptions, "signal">,
) => AsyncIterable<RpcEventPayload<D, Name>>
readonly on: <Name extends keyof D["events"] & string>(
name: Name,
handler: (event: RpcEventPayload<D, Name>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => () => void
}
}
type RpcEventPayloadFor<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string,
> = Omit<RpcEvent, "type" | "data"> & {
type: `rpc.${D["id"]}.${Name}`
data: Rpc.EventData<D["events"][Name]["schema"]>
}
export type RpcEventPayload<
D extends Rpc.PortableDefinition,
Name extends keyof D["events"] & string = keyof D["events"] & string,
> = { [K in Name]: RpcEventPayloadFor<D, K> }[Name]
export interface RpcApi<Options = RpcCallOptions> {
<D extends Rpc.PortableDefinition>(definition: D): RpcClient<D, Options>
}
export function makeRpc(
raw: ReturnType<typeof make>,
events: { subscribe(options?: Pick<RequestOptions, "signal">): AsyncIterable<EventSubscribeOutput> },
): RpcApi {
return (definition) => {
const subscribe = (
name: string,
options?: Pick<RequestOptions, "signal">,
): AsyncIterable<RpcEventPayload<Rpc.PortableDefinition>> => {
if (!Object.hasOwn(definition.events, name)) throw new Error(`Unknown RPC event: ${definition.id}.${name}`)
const type = eventType(definition, name)
return {
[Symbol.asyncIterator]() {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const iterator = (async function* () {
try {
for await (const published of events.subscribe({ signal })) {
if (signal.aborted) return
if (published.type !== type) continue
// SAFETY: The exact RPC type was selected above; Promise contracts require no client-side transform.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
yield published as RpcEventPayload<Rpc.PortableDefinition>
}
} catch (error) {
if (!signal.aborted) throw error
} finally {
controller.abort()
}
})()
return {
next: () => iterator.next(),
return: () => {
// Interrupt a pending source read before closing the generator.
controller.abort()
return iterator.return()
},
}
},
}
}
// SAFETY: Every runtime key comes from this definition's method and event maps, which define RpcClient's mapped keys.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return Object.assign(
Object.fromEntries(
Object.keys(definition.methods).map((name) => [
name,
async (input: unknown, options?: RpcCallOptions) => {
try {
const result = await raw.rpc.call(
{
rpcID: definition.id,
method: name,
// SAFETY: The method schema defines the accepted input; this assertion bridges it to the generic JSON transport.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
input: input as RpcCallInput["input"],
location: options?.location,
},
{ signal: options?.signal, headers: options?.headers },
)
return result.output
} catch (error) {
if (!isRpcError(error) && !isRpcInternalError(error)) throw error
throw error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data }
}
},
]),
),
{
events: {
subscribe,
on: (
name: string,
handler: (event: RpcEventPayload<Rpc.PortableDefinition>) => Promise<void> | void,
options?: Pick<RequestOptions, "signal">,
) => {
const controller = new AbortController()
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal
const source = subscribe(name, { signal })
void (async () => {
for await (const event of source) await handler(event)
})().catch((error: unknown) => console.error(error))
return () => controller.abort()
},
},
},
) as RpcClient<typeof definition>
}
}
function eventType(definition: Rpc.PortableDefinition, name: string) {
return `rpc.${definition.id}.${name}` as const
}
-60
View File
@@ -1,60 +0,0 @@
export * as RpcRuntime from "./rpc-runtime.js"
import type { Rpc } from "@opencode-ai/schema/rpc"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { RpcError, RpcInternalError } from "@opencode-ai/protocol/errors"
import { Effect, Schema } from "effect"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
export function read(schema: Rpc.Method["output"], value: unknown) {
// Standard Schema results have already been parsed by the server.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
export function readError(method: Rpc.Method, error: unknown): Effect.Effect<never, unknown> {
if (!(error instanceof RpcError) && !(error instanceof RpcInternalError)) return Effect.fail(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.fail(
error.data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data: error.data },
)
}
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) =>
Effect.fail(
data === undefined
? { type: error.type, message: error.message }
: { type: error.type, message: error.message, data },
),
),
)
}
export const event = Effect.fn("Client.Rpc.event")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
schema: Rpc.EventDefinition,
event: RpcEvent,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const data = yield* read(schema.schema, event.data)
// SAFETY: The event type was selected by the caller and data was decoded with this event's schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...event,
type: eventType(definition, name),
data,
} as Rpc.EventPayload<D, Name>
})
export function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["id"]}.${Name}` {
return `rpc.${definition.id}.${name}`
}
-137
View File
@@ -1,137 +0,0 @@
export * as SharedEvents from "./shared-events.js"
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
type Completion = { readonly error: unknown } | Record<string, never>
type Subscriber = {
push: (value: A) => Promise<void>
finish: (completion: Completion) => void
}
type Connection = {
controller: AbortController
subscribers: Set<Subscriber>
connected?: A
}
let current: Connection | undefined
const delivered = Promise.resolve()
function stop(connection: Connection) {
connection.connected = undefined
connection.controller.abort()
if (current === connection) current = undefined
}
async function run(connection: Connection) {
let iterator: AsyncIterator<A> | undefined
let completion: Completion = {}
try {
if (connection.controller.signal.aborted) return
iterator = connect(connection.controller.signal)[Symbol.asyncIterator]()
while (!connection.controller.signal.aborted) {
const item = await iterator.next()
if (item.done || connection.controller.signal.aborted) break
if (item.value.type === "server.connected") connection.connected = item.value
await Promise.all(Array.from(connection.subscribers, (subscriber) => subscriber.push(item.value)))
}
} catch (error) {
completion = { error }
} finally {
stop(connection)
try {
await iterator?.return?.()
} catch (error) {
if (!("error" in completion)) completion = { error }
}
connection.subscribers.forEach((subscriber) => subscriber.finish(completion))
}
}
return {
subscribe(options?: { readonly signal?: AbortSignal }): AsyncIterable<A> {
return {
[Symbol.asyncIterator]() {
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
let started = false
let completion: Completion | undefined
let connection: Connection | undefined
let offered: { readonly value: A; readonly accepted: ReturnType<typeof Promise.withResolvers<void>> } | undefined
function finish(result: Completion) {
completion = result
offered?.accepted.resolve()
offered = undefined
options?.signal?.removeEventListener("abort", abort)
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
pending.splice(0).forEach((request) => {
if ("error" in result) request.reject(result.error)
else request.resolve({ done: true, value: undefined })
})
}
function abort() {
finish({})
}
const subscriber: Subscriber = {
finish,
push(value) {
if (completion) return delivered
const request = pending.shift()
if (request) {
request.resolve({ done: false, value })
return delivered
}
const accepted = Promise.withResolvers<void>()
offered = { value, accepted }
return accepted.promise
},
}
function start() {
if (completion) return
const fresh = !current
connection = current ?? {
controller: new AbortController(),
subscribers: new Set<Subscriber>(),
}
current = connection
connection.subscribers.add(subscriber)
if (connection.connected) void subscriber.push(connection.connected)
if (fresh) void run(connection)
}
return {
next(): Promise<IteratorResult<A>> {
if (offered) {
const current = offered
offered = undefined
current.accepted.resolve()
return Promise.resolve({ done: false, value: current.value })
}
if (completion) {
if ("error" in completion) return Promise.reject(completion.error)
return Promise.resolve({ done: true, value: undefined })
}
if (options?.signal?.aborted) {
abort()
return Promise.resolve({ done: true, value: undefined })
}
const request = Promise.withResolvers<IteratorResult<A>>()
pending.push(request)
if (!started) {
started = true
options?.signal?.addEventListener("abort", abort, { once: true })
start()
}
return request.promise
},
return(): Promise<IteratorResult<A>> {
finish({})
return Promise.resolve({ done: true, value: undefined })
},
}
},
}
},
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value && event.value.durable)
if ("durable" in event.value)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
+1 -2
View File
@@ -51,7 +51,6 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
export type CreateDataInput = {
readonly api: () => OpenCodeClient
@@ -59,7 +58,7 @@ export type CreateDataInput = {
readonly event: {
readonly on: <Type extends OpenCodeEvent["type"]>(
type: Type,
handler: (event: OpenCodeEventMap[Type]) => void,
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) => () => void
readonly listen: (handler: (event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) => () => void
}
-2
View File
@@ -45,7 +45,6 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
sessionID: "ses_test",
key: "review-notes",
})
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
void [
effectSession,
@@ -55,7 +54,6 @@ void [
promiseList,
promisePut,
promiseRemove,
emptyRpcOutput,
exactVersion,
compatibleVersion,
]
+21 -34
View File
@@ -14,34 +14,34 @@ describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root.all, effect)).toEqual([])
expect(within(root.all, schema)).toEqual([])
expect(within(root.all, protocol)).toEqual([])
expect(within(root.all, core)).toEqual([])
expect(within(root.all, server)).toEqual([])
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network.eager, effect).length).toBeGreaterThan(0)
expect(within(network.eager, schema).length).toBeGreaterThan(0)
expect(within(network.eager, protocol).length).toBeGreaterThan(0)
expect(within(network.all, core)).toEqual([])
expect(within(network.all, server)).toEqual([])
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService.all, effect)).toEqual([])
expect(within(promiseService.all, schema)).toEqual([])
expect(within(promiseService.all, protocol)).toEqual([])
expect(within(promiseService.all, core)).toEqual([])
expect(within(promiseService.all, server)).toEqual([])
expect(within(promiseService, effect)).toEqual([])
expect(within(promiseService, schema)).toEqual([])
expect(within(promiseService, protocol)).toEqual([])
expect(within(promiseService, core)).toEqual([])
expect(within(promiseService, server)).toEqual([])
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
expect(within(effectService.all, core)).toEqual([])
expect(within(effectService.all, server)).toEqual([])
expect(within(effectService, effect).length).toBeGreaterThan(0)
expect(within(effectService, protocol).length).toBeGreaterThan(0)
expect(within(effectService, core)).toEqual([])
expect(within(effectService, server)).toEqual([])
})
})
@@ -70,21 +70,8 @@ async function bundleInputs(specifier: string, target: "browser" | "bun") {
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata: {
inputs: Record<string, { imports: Array<{ path: string; kind: string; external?: boolean }> }>
} = await Bun.file(metafile).json()
const inputs = new Map(Object.entries(metadata.inputs).map(([file, input]) => [resolve(directory, file), input]))
const eager = new Set<string>()
const visit = (file: string) => {
if (eager.has(file)) return
eager.add(file)
inputs
.get(file)
?.imports.filter((input) => !input.external && input.kind !== "dynamic-import")
.forEach((input) => visit(resolve(directory, input.path)))
}
visit(entrypoint)
return { all: Array.from(inputs.keys()), eager: Array.from(eager) }
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
-46
View File
@@ -24,7 +24,6 @@ test("exposes every standard HTTP API group", () => {
"file",
"command",
"skill",
"rpc",
"event",
"pty",
"experimental",
@@ -678,51 +677,6 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
})
})
test("native event signals cancel only their listener and close transport after the last listener", async () => {
const opened = Promise.withResolvers<Request>()
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
opened.resolve(request)
return new Response(
new ReadableStream({
start(controller) {
request.signal.addEventListener("abort", () => controller.error(request.signal.reason), { once: true })
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const first = new AbortController()
const second = new AbortController()
const one = client.event.subscribe({ signal: first.signal })[Symbol.asyncIterator]().next()
const two = client.event.subscribe({ signal: second.signal })[Symbol.asyncIterator]().next()
const request = await opened.promise
expect(request.headers.get("authorization")).toBe("Bearer events")
first.abort()
expect((await one).done).toBe(true)
expect(request.signal.aborted).toBe(false)
second.abort()
expect((await two).done).toBe(true)
expect(request.signal.aborted).toBe(true)
})
test("native pre-aborted event signals do not open a transport", async () => {
let requests = 0
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => {
requests++
return new Response(null)
},
})
expect((await client.event.subscribe({ signal: AbortSignal.abort() })[Symbol.asyncIterator]().next()).done).toBe(true)
expect(requests).toBe(0)
})
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
-495
View File
@@ -1,495 +0,0 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
id: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
id: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})
-361
View File
@@ -1,361 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Rpc } from "@opencode-ai/schema/rpc"
import { z } from "zod"
import { OpenCode } from "../src/promise/index"
const cleanup = new Set<() => void>()
afterEach(() => {
cleanup.forEach((close) => close())
cleanup.clear()
})
const Echo = Rpc.define({
id: "acme/jobs",
methods: {
echo: {
input: z.string(),
output: z.string(),
errors: { rejected: z.object({ reason: z.string() }) },
},
raw: { input: z.unknown(), output: z.unknown() },
ping: { input: z.undefined(), output: z.undefined() },
},
events: {
updated: { schema: z.object({ count: z.number() }) },
},
})
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
id: "evt_rpc",
created: 10,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { source: "test" },
data,
})
function http(fetch: (request: Request) => Response | Promise<Response>) {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
cleanup.add(() => server.stop(true))
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
}
function events() {
const requests: Request[] = []
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
const cancelled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
let stopped = false
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const abort = () => {
if (stopped) return
stopped = true
controller.error(request.signal.reason)
cancelled.resolve()
}
request.signal.addEventListener("abort", abort, { once: true })
cleanup.add(abort)
opened.resolve(controller)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
},
cancel() {
stopped = true
cancelled.resolve()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
return {
client,
requests,
cancelled: cancelled.promise,
async send(value: unknown) {
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
},
async end() {
stopped = true
return (await opened.promise).close()
},
async fail(error: Error) {
stopped = true
return (await opened.promise).error(error)
},
}
}
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
const client = http(async (request) => {
const body = await request.json()
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
return Response.json({ output: body.input })
})
expect(typeof client.rpc).toBe("function")
expect(typeof client.rpc.call).toBe("function")
expect(
await client.rpc(Echo).echo("hello", {
location: { directory: "/project with spaces", workspace: "wrk_test" },
headers: { authorization: "Bearer override", "x-call": "call" },
}),
).toBe("hello")
const url = new URL(requests[0].url)
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
expect(requests[0].body).toEqual({ input: "hello" })
expect(requests[0].method).toBe("POST")
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
expect(requests[0].headers.get("x-base")).toBe("base")
expect(requests[0].headers.get("x-call")).toBe("call")
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
expect(new URL(requests[1].url).search).toBe("")
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
})
test("no-input RPC methods and absent output use empty wrappers", async () => {
const client = http(async (request) => {
expect(await request.json()).toEqual({})
return Response.json({})
})
expect(await client.rpc(Echo).ping()).toBeUndefined()
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
})
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
const calls = { input: 0, output: 0 }
const input: StandardSchemaV1<string, number> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.input++
return { value: Number(value) }
},
},
}
const output: StandardSchemaV1<number, string> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.output++
return { value: String(value) }
},
},
}
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
return { issues: [{ message: "Expected count" }] }
return { value: { text: String(value.count) } }
},
},
}
const definition = Rpc.define({
id: "standard",
methods: { count: { input, output } },
events: { counted: { schema: eventOutput } },
})
const client = http(async (request) => {
expect(await request.json()).toEqual({ input: "41" })
return Response.json({ output: "42" })
})
expect(await client.rpc(definition).count("41")).toBe("42")
const source = events()
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
const next = iterator.next()
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
expect((await next).value?.data).toEqual({ text: "42" })
await iterator.return?.()
expect(calls).toEqual({ input: 0, output: 0 })
})
test("RPC method signals cancel an in-flight HTTP request", async () => {
const received = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const client = http(() => {
received.resolve()
return response.promise
})
const controller = new AbortController()
const result = client
.rpc(Echo)
.echo("hello", { signal: controller.signal })
.catch((error: unknown) => error)
await received.promise
controller.abort()
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
response.resolve(Response.json({ output: "late" }))
})
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
let requests = 0
const client = http(() => {
requests++
return Response.json({ output: "hello" })
})
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
expect(requests).toBe(0)
})
test("RPC declared HTTP failures propagate", async () => {
await expect(
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
.rpc(Echo)
.echo("hello"),
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
})
test("RPC method failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
const client = http(() => Response.json(response, { status: 400 }))
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
})
test("RPC transport failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
type: "rpc.internal",
message: "Failed",
})
})
test("native events and multiple RPC clients share one lazy source across locations", async () => {
const source = events()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const firstNext = first.next()
const secondNext = second.next()
const otherNext = other.next()
expect(await native.next()).toEqual({ done: false, value: connected })
expect(source.requests).toHaveLength(1)
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
expect(await late.next()).toEqual({ done: false, value: connected })
await Promise.all([native.return?.(), late.return?.()])
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
expect((await otherNext).value).toMatchObject({
type: "rpc.other.updated",
location: { directory: "/other" },
data: { count: 9 },
})
await other.return?.()
await source.send(rpcEvent({ count: 42 }))
const expected = {
id: "evt_rpc",
created: 10,
type: `rpc.${Echo.id}.updated`,
location: { directory: "/first" },
metadata: { source: "test" },
data: { count: 42 },
}
expect(await firstNext).toEqual({ done: false, value: expected })
expect(await secondNext).toEqual({ done: false, value: expected })
const next = first.next()
await source.send(rpcEvent({ count: 43 }, "/second"))
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
await Promise.all([first.return?.(), second.return?.()])
await source.cancelled
expect(source.requests[0].signal.aborted).toBe(true)
expect(source.requests).toHaveLength(1)
})
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
const source = events()
const controller = new AbortController()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
const second = secondEvents[Symbol.asyncIterator]()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const firstNext = first.next()
const secondNext = second.next()
await native.next()
expect((await first.return?.())?.done).toBe(true)
expect((await firstNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
controller.abort()
expect((await secondNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
const nativeNext = native.next()
const event = rpcEvent({ count: 42 })
await source.send(event)
expect(await nativeNext).toEqual({ done: false, value: event })
await native.return?.()
await source.cancelled
})
test("RPC callback subscriptions unsubscribe independently", async () => {
const source = events()
const received = Promise.withResolvers<unknown>()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
await native.next()
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
await source.send(rpcEvent({ count: 42 }))
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
unsubscribe()
unsubscribe()
expect(source.requests[0].signal.aborted).toBe(false)
await native.return?.()
await source.cancelled
})
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
const source = events()
const client = source.client.rpc(Echo)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failed: number[] = []
cleanup.add(release.resolve)
cleanup.add(
client.events.on("updated", async (event) => {
failed.push(event.data.count)
started.resolve()
await release.promise
throw new Error("Expected async RPC callback failure")
}),
)
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
const first = healthy.next()
await source.send(rpcEvent({ count: 1 }))
await started.promise
expect((await first).value.data.count).toBe(1)
const second = healthy.next()
await source.send(rpcEvent({ count: 2 }))
expect((await second).value.data.count).toBe(2)
expect(failed).toEqual([1])
release.resolve()
await healthy.return?.()
await source.cancelled
expect(failed).toEqual([1])
})
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
const source = events()
const broad: Rpc.PortableDefinition = Echo
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
const iterator = aborted[Symbol.asyncIterator]()
expect((await iterator.next()).done).toBe(true)
expect(source.requests).toHaveLength(0)
})
-281
View File
@@ -1,281 +0,0 @@
import { expect, test } from "bun:test"
import { SharedEvents } from "../src/shared-events"
type Event = { readonly type: string; readonly value?: number }
function source(cleanup?: Promise<void>) {
const connections: {
signal: AbortSignal
push: (event: Event) => void
close: () => void
fail: (error: unknown) => void
closing: Promise<void>
closed: Promise<void>
}[] = []
const opened: ReturnType<typeof Promise.withResolvers<void>>[] = []
return {
connections,
async at(index: number) {
if (!connections[index]) await (opened[index] ??= Promise.withResolvers<void>()).promise
return connections[index]
},
connect(signal: AbortSignal): AsyncIterable<Event> {
let controller!: ReadableStreamDefaultController<Event>
let ended = false
const closing = Promise.withResolvers<void>()
const closed = Promise.withResolvers<void>()
const stream = new ReadableStream<Event>({
start(value) {
controller = value
},
})
const close = () => {
if (ended) return
ended = true
controller.close()
}
signal.addEventListener("abort", close, { once: true })
connections.push({
signal,
push: (event) => controller.enqueue(event),
close,
fail(error) {
ended = true
controller.error(error)
},
closing: closing.promise,
closed: closed.promise,
})
opened[connections.length - 1]?.resolve()
return (async function* () {
try {
yield* stream
} finally {
signal.removeEventListener("abort", close)
closing.resolve()
await cleanup
closed.resolve()
}
})()
},
}
}
test("creation, subscription, and idle iterators are lazy", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const iterable = shared.subscribe()
const idle = iterable[Symbol.asyncIterator]()
expect(events.connections).toHaveLength(0)
expect(await idle.return!()).toEqual({ done: true, value: undefined })
expect(await idle.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
const active = iterable[Symbol.asyncIterator]()
const next = active.next()
expect(events.connections).toHaveLength(1)
events.connections[0].push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await active.return!()
await events.connections[0].closed
})
test("pre-aborted subscribers do not open a source", async () => {
const events = source()
const controller = new AbortController()
const iterator = SharedEvents.make(events.connect).subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
controller.abort()
expect(await iterator.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(0)
})
test("multiple consumers share one source and receive live native and RPC events", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [{ type: "server.connected" }, { type: "session.updated" }, { type: "rpc.example.updated", value: 1 }]) {
const reads = [first.next(), second.next()]
events.connections[0].push(event)
expect(await Promise.all(reads)).toEqual([
{ done: false, value: event },
{ done: false, value: event },
])
}
expect(events.connections).toHaveLength(1)
await first.return!()
expect(events.connections[0].signal.aborted).toBe(false)
const next = second.next()
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect((await next).value).toEqual({ type: "rpc.example.updated", value: 2 })
await second.return!()
await events.connections[0].closed
})
test("late consumers receive the latest connection marker but no business event replay", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const idle = shared.subscribe()[Symbol.asyncIterator]()
for (const event of [
{ type: "server.connected", value: 1 },
{ type: "server.connected", value: 2 },
{ type: "rpc.example.updated", value: 3 },
]) {
const next = first.next()
events.connections[0].push(event)
await next
}
expect(await idle.next()).toEqual({ done: false, value: { type: "server.connected", value: 2 } })
const next = idle.next()
events.connections[0].push({ type: "rpc.example.updated", value: 4 })
expect(await next).toEqual({ done: false, value: { type: "rpc.example.updated", value: 4 } })
expect(events.connections).toHaveLength(1)
await first.return!()
await idle.return!()
await events.connections[0].closed
})
test("abort removes only its subscriber; last return closes the native source and resolves pending reads", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const controller = new AbortController()
const first = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
const secondReads = [second.next(), second.next()]
controller.abort()
expect(await firstRead).toEqual({ done: true, value: undefined })
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(events.connections[0].signal.aborted).toBe(false)
await second.return!()
expect(await Promise.all(secondReads)).toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
expect(await second.next()).toEqual({ done: true, value: undefined })
})
test("breaking a native for-await loop closes the last source", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const consumed = (async () => {
for await (const event of shared.subscribe()) {
expect(event.type).toBe("server.connected")
break
}
})()
events.connections[0].push({ type: "server.connected" })
await consumed
expect(events.connections[0].signal.aborted).toBe(true)
await events.connections[0].closed
})
test("rapid resubscription opens a replacement while old cleanup finishes", async () => {
const cleanup = Promise.withResolvers<void>()
const events = source(cleanup.promise)
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const firstRead = first.next()
events.connections[0].push({ type: "server.connected", value: 1 })
await firstRead
await first.return!()
await events.connections[0].closing
const second = shared.subscribe()[Symbol.asyncIterator]()
const third = shared.subscribe()[Symbol.asyncIterator]()
const secondRead = second.next()
const thirdRead = third.next()
const controller = new AbortController()
const cancelled = shared.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
const cancelledRead = cancelled.next()
controller.abort()
expect(await cancelledRead).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(2)
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 2 })
expect(await Promise.all([secondRead, thirdRead])).toEqual([
{ done: false, value: { type: "server.connected", value: 2 } },
{ done: false, value: { type: "server.connected", value: 2 } },
])
cleanup.resolve()
await events.connections[0].closed
await second.return!()
await third.return!()
await replacement.closed
})
test("source EOF finishes all consumers and permits a fresh subscription without retry", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const reads = [first.next(), second.next()]
events.connections[0].push({ type: "server.connected", value: 1 })
await Promise.all(reads)
const nextReads = [first.next(), second.next()]
events.connections[0].push({ type: "rpc.example.updated", value: 2 })
expect(await Promise.all(nextReads)).toEqual([
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
{ done: false, value: { type: "rpc.example.updated", value: 2 } },
])
events.connections[0].close()
await events.connections[0].closed
expect(await first.next()).toEqual({ done: true, value: undefined })
expect(await second.next()).toEqual({ done: true, value: undefined })
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected", value: 3 })
expect(await next).toEqual({ done: false, value: { type: "server.connected", value: 3 } })
await fresh.return!()
await replacement.closed
})
test("source failures preserve error identity for every consumer and permit a new subscription", async () => {
const events = source()
const shared = SharedEvents.make(events.connect)
const first = shared.subscribe()[Symbol.asyncIterator]()
const second = shared.subscribe()[Symbol.asyncIterator]()
const failure = { reason: "actual source failure" }
const reads = Promise.allSettled([first.next(), second.next()])
events.connections[0].fail(failure)
expect(await reads).toEqual([
{ status: "rejected", reason: failure },
{ status: "rejected", reason: failure },
])
await expect(first.next()).rejects.toBe(failure)
expect(events.connections).toHaveLength(1)
const fresh = shared.subscribe()[Symbol.asyncIterator]()
const next = fresh.next()
const replacement = await events.at(1)
replacement.push({ type: "server.connected" })
expect(await next).toEqual({ done: false, value: { type: "server.connected" } })
await fresh.return!()
await replacement.closed
})
test("synchronous source creation failures reject subscribers without automatic retry", async () => {
const failure = new Error("connect failed")
const attempts: AbortSignal[] = []
const shared = SharedEvents.make<Event>((signal) => {
attempts.push(signal)
throw failure
})
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(1)
expect(attempts[0].aborted).toBe(true)
await expect(shared.subscribe()[Symbol.asyncIterator]().next()).rejects.toBe(failure)
expect(attempts).toHaveLength(2)
})
-25
View File
@@ -107,17 +107,6 @@ export function map(input: MapInput): Mapping | undefined {
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/mistral":
return {
package: "@opencode-ai/ai/providers/mistral",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapMistralOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
...(isRecord(input.settings.extraBody) ? { body: input.settings.extraBody } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode-ai/ai/providers/openai",
@@ -294,20 +283,6 @@ function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
return { providerOptions: options }
}
function mapMistralOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.safePrompt === "boolean" ? { safePrompt: settings.safePrompt } : {}),
...(typeof settings.documentImageLimit === "number" ? { documentImageLimit: settings.documentImageLimit } : {}),
...(typeof settings.documentPageLimit === "number" ? { documentPageLimit: settings.documentPageLimit } : {}),
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(settings.promptMode === "reasoning" ? { promptMode: settings.promptMode } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
+5 -16
View File
@@ -41,7 +41,7 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin entrypoints can live outside config roots, where the
// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
@@ -55,6 +55,9 @@ export const layer = Layer.effect(
if (watched.has(operation.target)) continue
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
// Directory targets can't hot-reload (their stat mtime ignores edits
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
@@ -141,22 +144,8 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
return { ...operation, target }
}),
)
const resolved = yield* Effect.forEach(configured, (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Option.some(operation)
if (yield* fs.isFile(operation.target)) {
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
return yield* Effect.forEach([...discovered, ...configured], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
-2
View File
@@ -30,7 +30,6 @@ import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
import { ShellSelect } from "./shell/select.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
@@ -63,7 +62,6 @@ const nodes = [
Agent.node,
Command.node,
Reference.node,
Rpc.node,
WebSearch.node,
Integration.node,
Catalog.node,
-2
View File
@@ -338,7 +338,6 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/groq" ||
name === "@ai-sdk/mistral" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
@@ -352,7 +351,6 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/groq" ||
name === "@opencode-ai/ai/providers/mistral" ||
name === "@opencode-ai/ai/providers/togetherai" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
+8 -12
View File
@@ -1,5 +1,5 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
@@ -19,7 +19,6 @@ import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { WebSearch } from "./websearch.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
@@ -31,17 +30,14 @@ import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Failure[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Versioned = PluginDefinition & {
readonly version: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -84,7 +80,7 @@ const layer = Layer.effect(
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Failure[] = [],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
@@ -126,8 +122,9 @@ const layer = Layer.effect(
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
@@ -178,8 +175,8 @@ function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
state: { status: "active" },
features: { server: true, ...plugin.features },
status: "active",
tui: plugin.tui ?? false,
}
}
@@ -198,7 +195,6 @@ export const node = makeLocationNode({
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
+1 -18
View File
@@ -3,7 +3,6 @@ export * as PluginHost from "./host.js"
import { Plugin } from "@opencode-ai/plugin/effect"
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import type { Event } from "@opencode-ai/schema/event"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
@@ -21,7 +20,6 @@ import { Mcp } from "../mcp/index.js"
import { PluginRuntime } from "./runtime.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
import { Rpc } from "../rpc.js"
import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
@@ -34,12 +32,6 @@ import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type RpcEvent = Event.Payload & {
readonly type: `rpc.${string}`
readonly location: Location.Ref
readonly data: Readonly<Record<string, unknown>>
}
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
const app = yield* App.Metadata
const agents = yield* Agent.Service
@@ -52,7 +44,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const mcp = yield* Mcp.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
const rpc = yield* Rpc.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
@@ -84,7 +75,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
app,
location: locationInfo(),
options: {},
rpc: Object.assign(rpc.client, { register: rpc.register }),
agent: {
get: (input) => {
const ref = locationRef(input)
@@ -201,14 +191,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
transform: commands.transform,
},
event: {
subscribe: () =>
bus
.subscribe()
.pipe(
Stream.filter(
(event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
),
),
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
experimental: {
terminal: {
+7 -35
View File
@@ -4,7 +4,6 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
@@ -15,15 +14,18 @@ const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
@@ -36,11 +38,9 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
@@ -49,20 +49,9 @@ export const load = Effect.fn("PluginModule.load")(function* (
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
@@ -71,20 +60,3 @@ export const load = Effect.fn("PluginModule.load")(function* (
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name))
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) =>
names.has(`${name}.${extension}`),
)
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+21 -5
View File
@@ -1,11 +1,18 @@
export * as PluginSourceDirectory from "./source-directory.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Option } from "effect"
import { Effect, Option, Predicate, Schema } from "effect"
import path from "path"
export const names = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
fs: FSUtil.Interface,
directory: string,
@@ -22,21 +29,30 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type === "directory") return yield* packageEntry(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
export function entrypoint(fs: FSUtil.Interface, directory: string) {
function packageEntry(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
+4 -6
View File
@@ -25,10 +25,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
@@ -61,8 +58,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
state: { status: "failed", error: plugin.error },
features: { server: true },
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
-1
View File
@@ -63,7 +63,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
],
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
["@opencode-ai/ai/providers/mistral", () => import("@opencode-ai/ai/providers/mistral")],
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
-275
View File
@@ -1,275 +0,0 @@
export * as Rpc from "./rpc.js"
export { define } from "@opencode-ai/schema/rpc"
export type { Definition, EventPayload, Failure } from "@opencode-ai/schema/rpc"
import type { RpcClient, RpcDomain, RpcHandlers } from "@opencode-ai/plugin/effect/rpc"
import type { Rpc } from "@opencode-ai/schema/rpc"
import { Event } from "@opencode-ai/schema/event"
import type { Tool } from "@opencode-ai/schema/tool"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { optional, statics } from "./schema.js"
export interface Interface {
readonly register: RpcDomain["register"]
readonly client: <D extends Rpc.Definition>(definition: D) => RpcClient<D, Rpc.SystemError, never, unknown>
readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect<unknown, Rpc.Failure>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Rpc") {}
class DeclaredError extends Error {
constructor(
readonly type: string,
message: string,
readonly data?: unknown,
) {
super(message)
}
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
const callContext = {
error: (type: string, message: string, data?: unknown) => new DeclaredError(type, message, data),
}
const registrations = new Map<
string,
Array<{
readonly definition: Rpc.Definition
readonly handlers: Readonly<Record<string, Function>>
}>
>()
const definitions = new WeakMap<
Rpc.Definition,
ReadonlyMap<string, { readonly event: Rpc.EventDefinition; readonly definition: Event.Definition }>
>()
const eventsFor = (definition: Rpc.Definition) => {
const existing = definitions.get(definition)
if (existing) return existing
const events = new Map(
Object.entries(definition.events).map(([name, event]) => [
name,
{ event, definition: eventDefinition(definition, name) },
]),
)
definitions.set(definition, events)
return events
}
const register = Effect.fn("Rpc.register")(function* <const D extends Rpc.Definition>(
definition: D,
handlers: RpcHandlers<NoInfer<D>>,
) {
const entry = { definition, handlers }
const dispose = Effect.sync(() => {
const remaining = (registrations.get(definition.id) ?? []).filter((candidate) => candidate !== entry)
if (remaining.length === 0) {
registrations.delete(definition.id)
return
}
registrations.set(definition.id, remaining)
})
yield* Effect.acquireRelease(
Effect.sync(() =>
registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry]),
),
() => dispose,
)
const events = eventsFor(definition)
return {
dispose,
events: {
emit: Effect.fn("Rpc.emit")(function* (...args: Rpc.EventInput<D>) {
const registered = events.get(args[0])
if (!registered)
return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.id}.${args[0]}`))
const event = registered.event
// SAFETY: The public event-schema contract guarantees an object encoded/output type.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
const data = (yield* encode(event.schema, args[1])) as Readonly<Record<string, unknown>>
return yield* bus
.publish(registered.definition, data, {
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
})
.pipe(Effect.asVoid)
}),
},
}
})
const call = Effect.fn("Rpc.call")(function* (rpcID: string, name: string, input: unknown) {
const entry = registrations.get(rpcID)?.at(-1)
if (!entry)
return yield* Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`))
if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name))
return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${rpcID}.${name}`))
const method = entry.definition.methods[name]
const handler = entry.handlers[name]
const parsed = yield* parse(method.input, input).pipe(
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
)
const result = yield* Effect.suspend(() => {
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
})
const client = <D extends Rpc.Definition>(definition: D): RpcClient<D, Rpc.SystemError, never, unknown> => {
const events = eventsFor(definition)
const methods = Object.fromEntries(
Object.entries(definition.methods).map(([name, method]) => [
name,
(input: unknown) =>
call(definition.id, name, input).pipe(
Effect.catch((error) => decodeError(method, error)),
Effect.flatMap((value) => read(method.output, value).pipe(Effect.catch((cause) => Effect.die(cause)))),
),
]),
)
// SAFETY: Every runtime key comes from this definition, and each method delegates through its corresponding schema.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...methods,
events: {
subscribe: <Name extends keyof D["events"] & string>(name: Name) => {
const registered = events.get(name)
if (!registered) return Stream.fail(new Error(`Unknown RPC event: ${definition.id}.${name}`))
return bus.subscribe(registered.definition).pipe(
Stream.provideService(Location.Service, location),
Stream.mapEffect((payload) => logicalEvent(definition, name, payload, ref)),
)
},
},
} as RpcClient<D, Rpc.SystemError, never, unknown>
}
return Service.of({ register, call, client })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Location.node] })
const fields = {
id: Event.ID,
created: Schema.Finite,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
location: optional(Location.Ref),
}
const EventData = Schema.Record(Schema.String, Schema.Unknown)
const jsonSchemas = new WeakMap<JsonSchema.JsonSchema, Schema.Codec<unknown>>()
function eventType<const D extends Rpc.Definition, const Name extends keyof D["events"] & string>(
definition: D,
name: Name,
): `rpc.${D["id"]}.${Name}` {
return `rpc.${definition.id}.${name}`
}
function eventDefinition(definition: Rpc.Definition, name: string): Event.Definition {
const type = eventType(definition, name)
const data = EventData
return Schema.Struct({ ...fields, type: Schema.Literal(type), data }).pipe(
statics(() => ({ type, durability: "ephemeral" as const, durable: undefined, data })),
) satisfies Event.EphemeralDefinition<string, typeof data>
}
function parse(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
if (Schema.isSchema(schema)) return Schema.decodeUnknownEffect(schema)(value)
if (isStandardSchema(schema)) {
return Effect.gen(function* () {
const parsed = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (cause) => cause })
const result =
parsed instanceof Promise ? yield* Effect.tryPromise({ try: () => parsed, catch: (cause) => cause }) : parsed
if (result.issues) return yield* Effect.fail(new Error(result.issues.map((issue) => issue.message).join("\n")))
return result.value
})
}
return Effect.try({
try: () => {
const existing = jsonSchemas.get(schema)
if (existing) return existing
const codec = Schema.make<Schema.Codec<unknown>>(
SchemaRepresentation.fromJsonSchemaDocument(JsonSchema.fromSchemaDraft2020_12(schema)).ast,
)
jsonSchemas.set(schema, codec)
return codec
},
catch: (cause) => cause,
}).pipe(Effect.flatMap((codec) => Schema.decodeUnknownEffect(codec)(value)))
}
function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
}
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
if (!(error instanceof DeclaredError)) return Effect.die(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
}
return encode(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
return read(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function failure(type: string, message: string, data?: unknown): Rpc.Failure {
return data === undefined ? { type, message } : { type, message, data }
}
function errorMessage(error: unknown, fallback: string) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
return fallback
}
function isStandardSchema(schema: Tool.ValueSchema): schema is Extract<Tool.ValueSchema, StandardSchemaV1> {
return "~standard" in schema
}
function read(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown, unknown> {
// Standard Schema results were already parsed by the publisher; don't apply transforms twice.
return Schema.isSchema(schema) ? Schema.decodeUnknownEffect(schema)(value) : Effect.succeed(value)
}
const logicalEvent = Effect.fn("Rpc.logicalEvent")(function* <
D extends Rpc.Definition,
Name extends keyof D["events"] & string,
>(
definition: D,
name: Name,
payload: Event.Payload,
ref: Location.Ref,
): Effect.fn.Return<Rpc.EventPayload<D, Name>, unknown> {
const event = definition.events[name]
const data = yield* read(event.schema, payload.data)
// SAFETY: The private Bus definition owns the envelope and location.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
return {
...payload,
type: eventType(definition, name),
data,
location: Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }),
} as Rpc.EventPayload<D, Name>
})
+30 -5
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@@ -50,6 +50,8 @@ import { Session } from "./session/session.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
import { Skill } from "./skill.js"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
@@ -200,9 +202,12 @@ export interface Interface {
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
readonly skill: (
input: Parameters<Session.Handle["skill"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["skill"]>
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
skill: Skill.ID
resume?: boolean
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
@@ -242,6 +247,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const scope = yield* Scope.Scope
const sessions = yield* Session.make((ref) => locations.get(ref))
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
@@ -429,7 +435,26 @@ const layer = Layer.effect(
})
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
skill: (input) => sessions.forSession(input.sessionID).skill(input),
skill: Effect.fn("Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
if (input.resume !== false)
yield* execution
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
@@ -3,7 +3,7 @@ import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Snapshot } from "@opencode-ai/schema/snapshot"
import { Effect, Fiber, Iterable } from "effect"
import { Clock, Effect, Iterable } from "effect"
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
import { Bus } from "../../bus.js"
import { SessionEvent } from "../event.js"
@@ -130,7 +130,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
readonly ordinal: number
readonly values: string[]
pending: string
timer?: Fiber.Fiber<void>
publishedAt?: number
state?: Record<string, unknown>
}
const chunks = new Map<string, Fragment>()
@@ -143,46 +143,36 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
chunks.set(id, { ordinal, values: [], pending: "", state })
return Effect.succeed(ordinal)
})
const publishDelta = Effect.fnUntraced(function* (id: string) {
const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
if (!delta) return undefined
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
if (!current.pending) return undefined
const value = current.pending
// New chunks can arrive while the timer is publishing this batch.
const now = yield* Clock.currentTimeMillis
if (!force && current.publishedAt === undefined) {
current.publishedAt = now
return undefined
}
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
return undefined
yield* delta(id, current.pending, current.ordinal)
current.pending = ""
yield* delta(id, value, current.ordinal)
current.publishedAt = now
return undefined
}, Effect.uninterruptible)
})
const append = Effect.fnUntraced(function* (id: string, value: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value)
if (delta) current.pending += value
if (state !== undefined) current.state = { ...current.state, ...state }
if (current.pending && !current.timer) {
// Own the trailing flush in the provider fiber, even if no more chunks arrive.
current.timer = yield* Effect.gen(function* () {
while (current.pending) {
yield* Effect.sleep(deltaBatchInterval)
yield* publishDelta(id)
}
}).pipe(
Effect.ensuring(
Effect.sync(() => {
current.timer = undefined
}),
),
Effect.forkChild({ startImmediately: true }),
)
}
yield* publishDelta(id)
return current.ordinal
})
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
if (current.timer) yield* Fiber.interrupt(current.timer)
yield* publishDelta(id)
yield* publishDelta(id, true)
yield* ended(
id,
value ?? current.values.join(""),
+1 -34
View File
@@ -9,7 +9,6 @@ import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
import { Skill } from "../skill.js"
import {
BusyError,
CompactionConflictError,
@@ -20,7 +19,6 @@ import {
MessageToolIncompleteError,
NotFoundError,
PromptConflictError,
SkillNotFoundError,
SyntheticConflictError,
} from "./error.js"
import { SessionEvent } from "./event.js"
@@ -32,12 +30,7 @@ import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
export type Services =
| PluginSupervisor.Service
| SessionPrompt.Service
| SessionRevert.Service
| Shell.Service
| Skill.Service
export type Services = PluginSupervisor.Service | SessionPrompt.Service | SessionRevert.Service | Shell.Service
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
@@ -245,29 +238,6 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
yield* Fiber.join(running)
})
const skill = Effect.fn("Session.skill")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
) {
const session = yield* get(sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
SessionEvent.Skill.Activated,
{
sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},
{ id: input.id ? Event.ID.make(input.id.replace(/^msg_/, "evt_")) : undefined },
)
if (input.resume !== false)
yield* execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
})
const compact = Effect.fn("Session.compact")(function* (
sessionID: SessionSchema.ID,
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
@@ -376,7 +346,6 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
prompt,
synthetic,
shell,
skill,
compact,
wait,
resume,
@@ -399,7 +368,6 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const prompt = operations.prompt.bind(undefined, sessionID)
const synthetic = operations.synthetic.bind(undefined, sessionID)
const shell = operations.shell.bind(undefined, sessionID)
const skill = operations.skill.bind(undefined, sessionID)
const compact = operations.compact.bind(undefined, sessionID)
const wait = operations.wait.bind(undefined, sessionID)
const resume = operations.resume.bind(undefined, sessionID)
@@ -425,7 +393,6 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
prompt,
synthetic,
shell,
skill,
compact,
wait,
resume,
+32 -70
View File
@@ -53,58 +53,29 @@ export const Plugin = {
messageID: context.messageID,
id: context.id,
}
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
Effect.gen(function* () {
if (target.externalDirectory && authorizeExternal)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [target.resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const target = yield* mutation.resolve({ path: input.path })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const read = (target: LocationMutation.Target) =>
reader.read(AbsolutePath.make(target.absolute), target.resource, {
offset: input.offset,
limit: input.limit,
})
const requested = yield* mutation.resolve({ path: input.path })
yield* authorize(requested)
const result = yield* read(requested).pipe(
Effect.map((content) => ({ content, target: requested, path: input.path })),
const resource = target.resource
const absolute = AbsolutePath.make(target.absolute)
yield* permission.assert({
action: name,
resources: [resource],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
() =>
Effect.gen(function* () {
const alternate = yield* alternatePath(requested.absolute).pipe(
Effect.orElseSucceed(() => undefined),
)
if (!alternate) return yield* missing(input.path, requested.absolute)
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
// The candidate is a sibling under the external directory already approved above.
yield* authorize(target, false)
const content = yield* read(target).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
() => missing(input.path, requested.absolute),
),
)
if (content.type === "list-page") return yield* missing(input.path, requested.absolute)
return {
content,
target,
path: join(dirname(input.path), basename(alternate)),
}
}),
() => missing(input.path, target.absolute),
),
)
// After a successful read, discover nearby AGENTS.md walking up to the Location
@@ -113,14 +84,14 @@ export const Plugin = {
// is discovered); for a file it starts at the file's dirname. External reads are
// skipped, and discovery failures never fail the read.
yield* Effect.gen(function* () {
if (result.target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(result.target.absolute)
if (target.externalDirectory !== undefined) return
const resolved = yield* fs.resolve(target.absolute)
const root = yield* fs.resolve(location.directory)
// up() searches its stop directory, so the Location-root AGENTS.md (already
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: result.content.type === "list-page" ? resolved : dirname(resolved),
start: content.type === "list-page" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
@@ -133,17 +104,17 @@ export const Plugin = {
Effect.catchDefect(() => Effect.void),
)
if (
result.content.type === "file" &&
result.content.encoding === "base64" &&
!ReadToolFileSystem.MEDIA_MIMES.has(result.content.mime)
content.type === "file" &&
content.encoding === "base64" &&
!ReadToolFileSystem.MEDIA_MIMES.has(content.mime)
)
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource: result.target.resource }))
return { output: result.content, path: result.path }
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((result) => ({
output: result.output,
content: toModelContent(result.path, input.offset, result.output),
metadata: { truncated: result.output.type === "file" ? false : result.output.truncated },
Effect.map((output) => ({
output,
content: toModelContent(input.path, input.offset, output),
metadata: { truncated: output.type === "file" ? false : output.truncated },
})),
Effect.mapError((error) => {
if (error instanceof ToolFailure) return error
@@ -162,15 +133,6 @@ export const Plugin = {
)
.pipe(Effect.orDie)
const alternatePath = Effect.fn("ReadTool.alternatePath")(function* (absolute: string) {
const base = basename(absolute).replace(/[\u00a0\u202f]/g, " ")
const matches = (yield* reader.list(AbsolutePath.make(dirname(absolute)))).filter(
(entry) => entry.type === "file" && entry.name.replace(/[\u00a0\u202f]/g, " ") === base,
)
if (matches.length !== 1) return
return join(dirname(absolute), matches[0].name)
})
const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
+1 -5
View File
@@ -101,7 +101,6 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {}
export interface Interface {
readonly list: (path: AbsolutePath) => ReturnType<Files["list"]>
readonly read: (
path: AbsolutePath,
resource: string,
@@ -379,10 +378,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Service.of({
list: environment.files.list,
read: (path, resource, page) => read(environment.files, path, resource, page),
})
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
}),
)
-60
View File
@@ -109,66 +109,6 @@ describe("AISDKNative", () => {
})
})
test("maps supported Mistral settings and request overlays to the native provider", () => {
expect(
map("@ai-sdk/mistral", {
apiKey: "secret",
baseURL: "https://mistral.example/v1",
headers: { "x-provider": "mistral" },
extraBody: { custom: { enabled: true } },
safePrompt: false,
documentImageLimit: 4,
documentPageLimit: 12,
parallelToolCalls: false,
promptCacheKey: "session-123",
reasoningEffort: "high",
promptMode: "reasoning",
fetch: "ignored",
generateId: "ignored",
structuredOutputs: true,
unsupported: true,
}),
).toEqual({
package: "@opencode-ai/ai/providers/mistral",
settings: {
apiKey: "secret",
baseURL: "https://mistral.example/v1",
providerOptions: {
safePrompt: false,
documentImageLimit: 4,
documentPageLimit: 12,
parallelToolCalls: false,
promptCacheKey: "session-123",
reasoningEffort: "high",
promptMode: "reasoning",
},
},
headers: { "x-provider": "mistral" },
body: { custom: { enabled: true } },
})
})
test("omits invalid and runtime-only Mistral settings", () => {
expect(
map("@ai-sdk/mistral", {
headers: { valid: "header", invalid: 1 },
extraBody: "invalid",
safePrompt: "false",
documentImageLimit: "4",
documentPageLimit: null,
parallelToolCalls: 0,
promptCacheKey: false,
reasoningEffort: false,
promptMode: "unsupported",
fetch: "ignored",
generateId: "ignored",
}),
).toEqual({
package: "@opencode-ai/ai/providers/mistral",
settings: {},
})
})
test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode-ai/ai/providers/amazon-bedrock",
+57 -42
View File
@@ -104,7 +104,7 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded from config" },
},
],
@@ -121,17 +121,17 @@ describe("PluginSupervisor config", () => {
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise/index.ts"),
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
state: { status: "active" },
features: { server: true, tui: true },
status: "active",
tui: true,
})
}),
),
)
it.live("disables configured plugins by exported ID", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, "-config-promise-plugin"] },
Effect.gen(function* () {
@@ -145,7 +145,7 @@ describe("PluginSupervisor config", () => {
})
it.live("does not disable configured plugins by package target", () => {
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise")
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
return withLocation(
{ plugins: [plugin, `-${plugin}`] },
Effect.gen(function* () {
@@ -162,7 +162,7 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-effect-plugin.ts"),
options: { description: "Effect plugin from config" },
},
],
@@ -191,9 +191,9 @@ describe("PluginSupervisor config", () => {
plugins: [
"-*",
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
{
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
package: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
options: { description: "Loaded after invalid plugins" },
},
],
@@ -207,13 +207,13 @@ describe("PluginSupervisor config", () => {
})
expect(output).toEqual([
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.state.status === "failed").map((plugin) => plugin.source),
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid/index.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
@@ -233,23 +233,35 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads conventional auto-discovered plugin entrypoints", () =>
it.live("loads auto-discovered plugin package entrypoints in order", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("package-index-ts")
expect(ids).toContain("package-index-js")
expect(ids).not.toContain("package-custom-entry")
expect(ids).toContain("package-exports")
expect(ids).toContain("package-module")
expect(ids).toContain("package-main")
expect(ids).toContain("package-index")
}),
false,
async (directory) => {
await Promise.all([
writeDiscoveredPackage(directory, "ts", { "index.ts": "package-index-ts" }),
writeDiscoveredPackage(directory, "js", { "index.js": "package-index-js" }),
writeDiscoveredPackage(directory, "custom", { "entry.ts": "package-custom-entry" }),
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
writeDiscoveredPackage(
directory,
"module",
{ exports: "./missing.js", module: "./entry.js" },
{ "entry.js": "package-module" },
),
writeDiscoveredPackage(
directory,
"main",
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
{ "entry.js": "package-main" },
),
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
])
},
),
@@ -270,11 +282,21 @@ describe("PluginSupervisor config", () => {
async (directory) => {
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
await writeDiscoveredPackage(directory, "contained", { "index.js": "contained-fallback" })
await writeDiscoveredPackage(directory, "symlink", { "index.js": "symlink-fallback" })
await writeDiscoveredPackage(
directory,
"contained",
{ exports: "../../escape.js" },
{ "index.js": "contained-fallback" },
)
await writeDiscoveredPackage(
directory,
"symlink",
{ exports: "./entry.js" },
{ "index.js": "symlink-fallback" },
)
await fs.symlink(
path.join(directory, ".opencode", "escape.js"),
path.join(directory, ".opencode", "plugins", "symlink", "index.ts"),
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
)
},
),
@@ -285,7 +307,7 @@ describe("PluginSupervisor config", () => {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
yield* withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise")] },
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
@@ -343,15 +365,15 @@ describe("PluginSupervisor config", () => {
),
)
it.live("reloads a configured plugin when its entrypoint changes", () =>
it.live("reloads a configured plugin when its source file changes", () =>
withLocation(
{ plugins: ["-*", "./external"] },
{ plugins: ["-*", "./external/mutable.ts"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const file = path.join(location.directory, "external", "index.ts")
const file = path.join(location.directory, "external", "mutable.ts")
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
@@ -373,22 +395,11 @@ describe("PluginSupervisor config", () => {
// configured-entrypoint watch can observe the edit.
const external = path.join(directory, "external")
await fs.mkdir(external, { recursive: true })
await fs.writeFile(path.join(external, "index.ts"), mutablePlugin("first"))
await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
},
),
)
it.live("skips configured local files", () =>
withLocation(
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).not.toContain("config-promise-plugin")
}),
),
)
it.live("applies explicit removals after auto-discovery", () =>
withLocation(
{ plugins: ["-*"] },
@@ -408,8 +419,8 @@ describe("PluginSupervisor config", () => {
yield* withLocation(
{
plugins: [
path.join(import.meta.dir, "../plugin/fixtures/config-promise"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source"),
path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"),
],
},
Effect.gen(function* () {
@@ -437,7 +448,7 @@ describe("PluginSupervisor config", () => {
it.live("allows variant generation to be disabled", () =>
withLocation(
{
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source"), "-opencode.variant"],
plugins: [path.join(import.meta.dir, "../plugin/fixtures/variant-source-plugin.ts"), "-opencode.variant"],
},
Effect.gen(function* () {
yield* ready()
@@ -581,9 +592,13 @@ function discoveredPlugin(id: string) {
async function writeDiscoveredPackage(
directory: string,
name: string,
manifest: Record<string, unknown> | undefined,
files: Record<string, string>,
) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all(Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))))
await Promise.all([
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
])
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
const selected = Info.make({
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
package: Provider.aisdk("@ai-sdk/cohere"),
package: Provider.aisdk("@ai-sdk/mistral"),
})
const runtime = LanguageModel.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
+7 -6
View File
@@ -347,7 +347,7 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() =>
fs.writeFile(
file,
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect")] }),
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
),
)
yield* Fiber.join(updated)
@@ -561,20 +561,21 @@ describe("LocationServiceMap", () => {
fs.writeFile(
file,
JSON.stringify({
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing")],
plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
}),
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).some((plugin) => plugin.state.status === "failed")) break
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing/index.ts") },
state: { status: "failed", error: expect.stringContaining("plugin failed") },
features: { server: true },
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
+27 -104
View File
@@ -280,25 +280,12 @@ describe("ModelResolver", () => {
),
)
it.effect("uses no native API-key auth for explicitly enabled providers without credentials", () => {
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
const selected = model(Provider.aisdk("@ai-sdk/google"), {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://gateway.example.com/v1" },
headers: { "cf-access-token": "access-token" },
})
const selections = [
selected,
model(Provider.aisdk("@ai-sdk/mistral"), {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://mistral.example.com/v1" },
headers: { "cf-access-token": "access-token" },
}),
model("@opencode-ai/ai/providers/mistral", {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://native-mistral.example.com/v1" },
headers: { "cf-access-token": "access-token" },
}),
]
const provider = Provider.Info.make({
...Provider.Info.empty(selected.providerID),
activation: "enabled",
@@ -357,23 +344,20 @@ describe("ModelResolver", () => {
return withConfigEnv({}, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
yield* Effect.forEach(selections, (selection) =>
Effect.gen(function* () {
const resolved = yield* resolver.resolveModel(selection)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: resolved.model.route.endpoint.baseURL ?? "",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
const resolved = yield* resolver.resolveModel(selected)
expect(resolved.limit).toEqual(selection.limit)
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}),
)
expect(resolved.limit).toEqual(selected.limit)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: "https://gateway.example.com/v1",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
})
@@ -937,24 +921,6 @@ describe("ModelResolver", () => {
{ reasoningEffort: "high", parallelToolCalls: false },
{ reasoningEffort: "high", parallelToolCalls: false },
],
[
"@ai-sdk/mistral",
"@opencode-ai/ai/providers/mistral",
{
safePrompt: true,
documentImageLimit: 4,
promptCacheKey: "session-123",
promptMode: "reasoning",
reasoningEffort: "high",
},
{
safePrompt: true,
documentImageLimit: 4,
promptCacheKey: "session-123",
promptMode: "reasoning",
reasoningEffort: "high",
},
],
[
"@ai-sdk/togetherai",
"@opencode-ai/ai/providers/togetherai",
@@ -1014,7 +980,6 @@ describe("ModelResolver", () => {
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
["@ai-sdk/mistral", "@opencode-ai/ai/providers/mistral", "api-model"],
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
@@ -1123,36 +1088,6 @@ describe("ModelResolver", () => {
),
)
it.effect("merges mapped Mistral headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: {
headers: { "x-factory": "factory", "x-shared": "factory" },
extraBody: { factory: true, custom: { source: true } },
},
headers: { "x-shared": "catalog" },
body: { custom: { catalog: true } },
}),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.headers).toEqual({
"x-factory": "factory",
"x-shared": "catalog",
})
expect(settings.body).toEqual({
factory: true,
custom: { source: true, catalog: true },
})
return LanguageModel.make({ id: modelID, provider: "mistral", route: OpenAIChat.route })
},
}),
},
),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
@@ -1179,11 +1114,6 @@ describe("ModelResolver", () => {
settings: { reasoningEffort: "high", parallelToolCalls: false },
}),
)
const mistral = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { safePrompt: true, promptCacheKey: "session-123", reasoningEffort: "high" },
}),
)
const xai = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
)
@@ -1218,13 +1148,6 @@ describe("ModelResolver", () => {
expect(groq.route.protocol).toBe("groq-chat")
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
expect(String(groq.provider)).toBe("test-provider")
expect(mistral.route.id).toBe("mistral-chat")
expect(mistral.route.defaults.providerOptions).toEqual({
safePrompt: true,
promptCacheKey: "session-123",
reasoningEffort: "high",
})
expect(String(mistral.provider)).toBe("test-provider")
expect(xai.route.id).toBe("openai-responses")
expect(xai.route.defaults.providerOptions).toEqual({
reasoningEffort: "high",
@@ -1247,8 +1170,8 @@ describe("ModelResolver", () => {
}),
)
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/cohere"), {
modelID: "cohere-api-model",
model(Provider.aisdk("@ai-sdk/mistral"), {
modelID: "mistral-api-model",
settings: { project: "test" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -1263,9 +1186,9 @@ describe("ModelResolver", () => {
Effect.sync(() => {
expect(runtime).toMatchObject({
id: "test-model",
modelID: "cohere-api-model",
modelID: "mistral-api-model",
providerID: "test-provider",
package: Provider.aisdk("@ai-sdk/cohere"),
package: Provider.aisdk("@ai-sdk/mistral"),
settings: { project: "test", apiKey: "fallback-secret", accountId: "account" },
headers: { "x-aisdk": "header" },
body: { custom: true },
@@ -1279,7 +1202,7 @@ describe("ModelResolver", () => {
},
)
expect(resolved).toMatchObject({ id: "cohere-api-model", provider: "test-provider" })
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
}),
)
@@ -1287,7 +1210,7 @@ describe("ModelResolver", () => {
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/cohere"), {
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
@@ -1306,7 +1229,7 @@ describe("ModelResolver", () => {
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/cohere"), {
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
}),
undefined,
@@ -1343,8 +1266,8 @@ describe("ModelResolver", () => {
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/cohere"), {
settings: { baseURL: "https://cohere.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://mistral.example/v1" },
}),
).pipe(Effect.flip)
@@ -1352,9 +1275,9 @@ describe("ModelResolver", () => {
_tag: "SessionRunnerModel.UnsupportedPackageError",
providerID: "test-provider",
modelID: "test-model",
package: "aisdk:@ai-sdk/cohere",
package: "aisdk:@ai-sdk/mistral",
})
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/cohere")
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
}),
)
@@ -1366,8 +1289,8 @@ describe("ModelResolver", () => {
}),
)
yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/cohere"), {
settings: { apiKey: "", baseURL: "https://cohere.example/v1" },
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
}),
undefined,
{
+17 -53
View File
@@ -268,8 +268,9 @@ describe("Plugin", () => {
[
{
source: { type: "package", package: "broken" },
state: { status: "failed", error: "failed to resolve" },
features: { server: true },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
@@ -330,27 +331,7 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([
{ id: active, source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
])
}),
)
it.effect("reports activated and discovered plugin features", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.activate([
{ id: "rpc-plugin", version: "1", features: { rpc: true }, effect: () => Effect.void },
])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("rpc-plugin"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true, rpc: true },
},
])
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
}),
)
@@ -380,17 +361,13 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("materialization failed") },
features: { server: true },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
@@ -398,18 +375,8 @@ describe("Plugin", () => {
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("good"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
@@ -446,12 +413,7 @@ describe("Plugin", () => {
])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("partial-tools"),
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
@@ -495,8 +457,9 @@ describe("Plugin", () => {
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
@@ -534,8 +497,9 @@ describe("Plugin", () => {
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
state: { status: "failed", error: expect.stringContaining("replacement failed") },
features: { server: true },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
-2
View File
@@ -22,7 +22,6 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Permission } from "@opencode-ai/core/permission"
import { Reference } from "@opencode-ai/core/reference"
import { Rpc } from "@opencode-ai/core/rpc"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
@@ -80,7 +79,6 @@ export const PluginTestLayer = LayerNode.compile(
Permission.node,
PluginHooks.node,
Reference.node,
Rpc.node,
Skill.node,
SkillDiscovery.node,
Tool.node,
@@ -1 +0,0 @@
export { default } from "../config-effect-plugin"
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
@@ -1 +0,0 @@
export { default } from "../config-promise-plugin"
@@ -1 +0,0 @@
export default { id: "config-promise-plugin.tui", setup() {} }
@@ -1 +0,0 @@
export { default } from "../failing-plugin"
@@ -1 +0,0 @@
export { default } from "../invalid-plugin"
@@ -1 +0,0 @@
export { default } from "../variant-source-plugin"
-8
View File
@@ -29,14 +29,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
}),
options: {},
rpc:
overrides.rpc ??
Object.assign(
() => {
throw new Error("unused rpc.client")
},
{ register: () => Effect.die("unused rpc.register") },
),
agent: overrides.agent ?? {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
+2 -7
View File
@@ -17,11 +17,7 @@ test("loads cached plugin packages without requesting a refresh", async () => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: (_pkg, options) =>
Effect.sync(() => {
calls.push(options)
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
}),
resolve: () => Effect.die(new Error("Unexpected resolve")),
which: () => Effect.die(new Error("Unexpected which")),
}),
),
@@ -29,6 +25,5 @@ test("loads cached plugin packages without requesting a refresh", async () => {
)
expect(plugin.id).toBe("config-effect-plugin")
expect(plugin.features).toEqual({ tui: true, rpc: true })
expect(calls).toEqual([{ subpaths: ["server", ""] }, { subpaths: ["tui"] }, { subpaths: ["rpc"] }])
expect(calls).toEqual([{ subpaths: ["server", ""] }])
})
@@ -1,106 +0,0 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { Rpc } from "@opencode-ai/core/rpc"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { PluginTestLayer } from "./fixture"
import { Effect, Exit, Schema } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginTestLayer)
const Echo = Rpc.define({
id: "shared-echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
fail: {
input: Schema.String,
output: Schema.String,
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
},
},
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const events: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type !== "rpc.shared-echo.updated") return
expect(event.location).toEqual({ directory: location.directory })
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
events.push(event.data.text)
}),
)
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
echo: (value) => Effect.succeed(`${value}!`),
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
})
yield* registration.events.emit("updated", { text: "ready" })
}).pipe(Effect.orDie),
},
{
id: "consumer",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
}).pipe(Effect.orDie),
},
])
expect(events).toEqual(["ready"])
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
yield* plugins.activate([])
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
yield* unsubscribe
}),
)
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
yield* plugins.activate([
{
id: "implementer",
version: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("original"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.asVoid, Effect.orDie),
},
])
yield* plugins.activate([
{
id: "implementer",
version: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("replacement"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
},
])
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
@@ -1,291 +0,0 @@
import { describe, expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Logger } from "effect"
import { z } from "zod"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("Promise plugin RPC", () => {
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-calls",
methods: {
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
ping: { input: z.undefined(), output: z.null() },
errorShapedOutput: {
input: z.undefined(),
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
},
returned: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
thrown: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
defect: { input: z.undefined(), output: z.null() },
},
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-calls-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {
standard: async (input) => {
expect(input).toBe(42)
return input + 1
},
ping: async () => null,
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
returned: async (_input, context) =>
context.error("rejected", "returned failure", { attempts: "1" }),
thrown: async (_input, context) => {
throw context.error("rejected", "thrown failure", { attempts: "2" })
},
defect: async () => {
throw new Error("handler defect")
},
})
const client = ctx.rpc(service)
expect(await client.standard("42")).toBe("43")
expect(await client.ping()).toBeNull()
expect(await client.errorShapedOutput()).toEqual({
type: "ordinary",
message: "Success",
data: { value: 1 },
})
await expect(client.returned()).rejects.toEqual({
type: "rejected",
message: "returned failure",
data: { attempts: 1 },
})
await expect(client.thrown()).rejects.toEqual({
type: "rejected",
message: "thrown failure",
data: { attempts: 2 },
})
await expect(client.defect()).rejects.toThrow("handler defect")
await registration.dispose()
await registration.dispose()
await expect(client.ping()).rejects.toBeDefined()
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-cancel",
methods: { wait: { input: z.string(), output: z.string() } },
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-cancel-plugin",
setup: async (ctx) => {
const started = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const signals = new Map<string, AbortSignal>()
await ctx.rpc.register(service, {
wait: async (input, call) => {
signals.set(input, call.signal)
if (input === "complete") return input
started.resolve()
await new Promise<void>((resolve) => {
call.signal.addEventListener(
"abort",
() => {
cancelled.resolve()
resolve()
},
{ once: true },
)
})
return input
},
})
const client = ctx.rpc(service)
const controller = new AbortController()
const pending = client.wait("cancel", { signal: controller.signal })
const rejected = pending.then(
() => false,
() => true,
)
await started.promise
expect(await client.wait("complete")).toBe("complete")
controller.abort()
expect(await rejected).toBe(true)
await cancelled.promise
expect(signals.get("cancel")?.aborted).toBe(true)
expect(signals.get("complete")?.aborted).toBe(false)
expect(await client.wait("complete")).toBe("complete")
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-async-listeners",
methods: {},
events: { updated: { schema: z.object({ value: z.number() }) } },
})
const error = new Error("Expected async plugin callback failure")
const reported = Promise.withResolvers<void>()
const logger = Logger.make((entry) => {
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-async-listeners-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const third = Promise.withResolvers<void>()
const failed: number[] = []
const healthy: number[] = []
client.events.on("updated", async (event) => {
failed.push(event.data.value)
started.resolve()
await release.promise
throw error
})
client.events.on("updated", (event) => {
healthy.push(event.data.value)
if (event.data.value === 2) second.resolve()
if (event.data.value === 3) third.resolve()
})
await registration.events.emit("updated", { value: 1 })
await started.promise
await registration.events.emit("updated", { value: 2 })
await second.promise
expect(failed).toEqual([1])
release.resolve()
await reported.promise
await registration.events.emit("updated", { value: 3 })
await third.promise
expect(failed).toEqual([1])
expect(healthy).toEqual([1, 2, 3])
},
}),
)
yield* plugins
.activate([{ ...adapted, version: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
}),
)
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-events",
methods: {},
events: {
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
const subscriptions = Promise.withResolvers<{
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
nativeIdle: AsyncIterator<unknown>
}>()
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-events-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const first: string[] = []
const second: string[] = []
const firstSeen = Promise.withResolvers<void>()
const secondSeen = Promise.withResolvers<void>()
const nextSeen = Promise.withResolvers<void>()
const unsubscribe = client.events.on("counted", (event) => {
first.push(event.data.text)
firstSeen.resolve()
})
client.events.on("counted", (event) => {
second.push(event.data.text)
if (event.data.text === "1") secondSeen.resolve()
if (event.data.text === "2") nextSeen.resolve()
})
const controller = new AbortController()
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
const next = iterator.next()
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
const idleNext = idle.next()
const nativeController = new AbortController()
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
const nativeNext = native.next()
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
const nativeIdleNext = nativeIdle.next()
await registration.events.emit("counted", { count: 1 })
await Promise.all([firstSeen.promise, secondSeen.promise])
const event = (await next).value
expect(event.type).toBe("rpc.promise-rpc-events.counted")
expect(event.data).toEqual({ text: "1" })
expect(typeof event.location.directory).toBe("string")
expect((await idleNext).value.data).toEqual({ text: "1" })
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
nativeController.abort()
expect((await native.next()).done).toBe(true)
unsubscribe()
unsubscribe()
controller.abort()
expect((await iterator.next()).done).toBe(true)
await registration.events.emit("counted", { count: 2 })
await nextSeen.promise
expect(first).toEqual(["1"])
expect(second).toEqual(["1", "2"])
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
expect((await aborted.next()).done).toBe(true)
subscriptions.resolve({
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
idle,
nativeIdle,
})
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
}),
)
})

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