Compare commits

..
Author SHA1 Message Date
rekram1-node f4f781da7b fix(util): isolate temporary scratch files 2026-08-24 14:53:18 +00:00
216 changed files with 2815 additions and 13429 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
"x86_64-linux": "sha256-phyTF0/jQZ3L0B66PSLdpH//kyPc1M6j5a40wCSx7TA=",
"aarch64-linux": "sha256-1Zb/Is0ujIslCbPPusAVhcuzAPyIauQyeIIRRGtzpAk=",
"aarch64-darwin": "sha256-DDsVm7z+PSDry6QqrwVDFSmEnq6jIKb709Y4ymAv9f8=",
"x86_64-darwin": "sha256-S+5LI2J+WRhRP7jp2PAv6AesXk238wEYoyIO1oKdF3w="
}
}
@@ -157,7 +157,7 @@ type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
const AnthropicThinkingBlock = Schema.Struct({
type: Schema.tag("thinking"),
thinking: Schema.String,
signature: Schema.String,
signature: Schema.optional(Schema.String),
cache_control: Schema.optional(AnthropicCacheControl),
})
@@ -701,26 +701,6 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
const requireThinkingSignature = (request: LLMRequest) => {
if (request.model.compatibility?.requireSignature !== undefined)
return request.model.compatibility.requireSignature
const provider = request.model.provider.toLowerCase()
const model = request.model.id.toLowerCase()
const baseURL = (request.model.route.endpoint.baseURL ?? "").toLowerCase()
if (
provider === "kimi-for-coding" ||
provider === "moonshotai" ||
provider === "moonshotai-cn" ||
model.startsWith("kimi-") ||
baseURL.includes("api.kimi.com/coding") ||
baseURL.includes("api.moonshot.ai/anthropic") ||
baseURL.includes("api.moonshot.cn/anthropic")
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
@@ -827,30 +807,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
// Mirrors Vercel's @ai-sdk/anthropic: a signature marks visible
// thinking; only signature-less parts carrying redactedData
// round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
const redactedData = redactedDataFromMetadata(part.providerMetadata)
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
}
if (typeof signature !== "string" || signature.trim().length === 0) {
if (part.text.trim().length === 0) continue
if (!requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
continue
}
// Without a signature this cannot be a valid thinking block per
// the SDK ThinkingBlockParam:3217 — demote to text so the
// conversation remains sendable.
content.push({
type: "text",
text: part.text,
cache_control: cacheControl(breakpoints, part.cache),
})
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
continue
}
@@ -716,7 +716,7 @@ export const protocol = Protocol.make({
reasoningSignatures: {},
}),
step,
onHalt: (state) => Effect.succeed(onHalt(state)),
onHalt,
},
})
+5 -30
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -125,7 +125,6 @@ const GeminiContentPart = Schema.Union([
GeminiFunctionCallPart,
GeminiFunctionResponsePart,
])
const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart)
const GeminiContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
@@ -133,11 +132,6 @@ const GeminiContent = Schema.Struct({
})
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
const GeminiResponseContent = Schema.Struct({
role: optionalNull(Schema.Literals(["user", "model"])),
parts: optionalNull(Schema.Array(Schema.Unknown)),
})
const GeminiSystemInstruction = Schema.Struct({
parts: Schema.Array(Schema.Struct({ text: Schema.String })),
})
@@ -206,7 +200,7 @@ const GeminiUsage = Schema.Struct({
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
content: optionalNull(GeminiResponseContent),
content: optionalNull(GeminiContent),
finishReason: optionalNull(Schema.String),
})
@@ -228,7 +222,6 @@ const GeminiEvent = Schema.Struct({
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly route: string
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly promptFeedback?: GeminiPromptFeedback
@@ -605,21 +598,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
const seenCallIds = new Set(nextState.seenCallIds)
for (const input of candidate.content.parts ?? []) {
if (
ProviderShared.isRecord(input) &&
!("text" in input) &&
!("inlineData" in input) &&
!("functionCall" in input) &&
!("functionResponse" in input)
)
continue
const decoded = decodeGeminiContentPart(input)
if (Option.isNone(decoded))
return Effect.fail(
ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const part = decoded.value
for (const part of candidate.content.parts ?? []) {
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
// each block kind must retain the signature attached to its own parts.
@@ -712,13 +691,9 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
route: `${request.model.provider}/${request.model.route.id}`,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
}),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: (state) => Effect.succeed(finish(state)),
onHalt: finish,
},
})
+5 -25
View File
@@ -285,7 +285,6 @@ export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
@@ -667,7 +666,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
@@ -685,18 +683,11 @@ const lowerOptions = (request: LLMRequest) => {
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}),
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
...(options.truncation ? { truncation: options.truncation } : {}),
}
}
export const resolveParallelToolCalls = (request: LLMRequest) => {
const configured = OpenResponsesOptions.resolve(request).parallelToolCalls
if (configured !== undefined) return configured
const disabled = request.toolChoice?.disableParallelToolUse
return disabled === undefined ? undefined : !disabled
}
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
@@ -997,24 +988,12 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
if (event.type === "response.function_call_arguments.done" && final === undefined)
return [state, NO_EVENTS] satisfies StepResult
if (final !== undefined && !final.startsWith(tool.input))
return [
{ ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) },
NO_EVENTS,
] satisfies StepResult
const delta = final === undefined ? event.delta : final.slice(tool.input.length)
if (!delta) return [state, NO_EVENTS] satisfies StepResult
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
state.id,
state.tools,
event.item_id,
delta,
event.delta,
`${state.name} tool argument delta is missing its tool call`,
)
if (ToolStream.isError(result)) return yield* result
@@ -1207,6 +1186,7 @@ export const step = (state: ParserState, event: Event) => {
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1225,7 +1205,7 @@ export const step = (state: ParserState, event: Event) => {
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
if (event.type === "response.function_call_arguments.delta")
return event.item_id
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
+34 -234
View File
@@ -7,10 +7,7 @@ import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
AIError,
InvalidProviderOutputReason,
LLMEvent,
ProviderInternalReason,
UnknownProviderReason,
Usage,
type FinishReason,
type FinishReasonDetails,
@@ -54,12 +51,7 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
}),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -141,7 +133,6 @@ export const bodyFields = {
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
tool_stream: Schema.optional(Schema.Boolean),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
@@ -227,22 +218,16 @@ const OpenAIChatChoice = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.StructWithRest(
Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const OpenAIChatError = Schema.Struct({
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
message: Schema.String,
})
export const OpenAIChatEvent = Schema.StructWithRest(
Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export const OpenAIChatEvent = Schema.Struct({
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
usage: optionalNull(OpenAIChatUsage),
error: optionalNull(OpenAIChatError),
})
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
@@ -265,7 +250,6 @@ export interface ParserState {
readonly reasoningEmitted: boolean
readonly latestToolIndex?: number
readonly nextToolIndex: number
readonly requireFinishReason: boolean
}
// =============================================================================
@@ -280,18 +264,12 @@ interface LoweringOptions {
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
})
@@ -550,122 +528,11 @@ const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>)
return false
}
// Derive `max_tokens` vs `max_completion_tokens` from provider/baseURL when
// explicit `compatibility.maxTokensField` is not set. Aligned with
// models.dev provider naming: DeepSeek, Moonshot AI, Together AI, ZAI
// (Zhipu + Coding Plan variants), Nvidia, Cerebras, Chutes, etc. still
// require `max_tokens`.
const detectMaxTokensField = (provider: string, baseURL: string | undefined): "max_tokens" | "max_completion_tokens" => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
if (
p === "deepseek" ||
url.includes("deepseek.com") ||
p === "moonshotai" ||
url.includes("api.moonshot.ai") ||
p === "togetherai" ||
url.includes("api.together.") ||
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn") ||
p === "nvidia" ||
url.includes("integrate.api.nvidia.com") ||
p === "cerebras" ||
url.includes("cerebras.ai") ||
url.includes("llm.chutes.ai") ||
p === "chutes" ||
p === "cloudflare-ai-gateway" ||
url.includes("gateway.ai.cloudflare.com") ||
p === "cloudflare-workers-ai" ||
url.includes("api.cloudflare.com")
)
return "max_tokens"
return "max_completion_tokens"
}
const detectSupportsStore = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
const isDeepSeek = p === "deepseek" || url.includes("deepseek.com")
const isCerebras = p === "cerebras" || url.includes("cerebras.ai")
const isXai = p === "xai" || url.includes("api.x.ai")
const isChutes = p === "chutes" || url.includes("chutes.ai")
const isCloudflareWorkersAI = p === "cloudflare-workers-ai" || url.includes("api.cloudflare.com")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isVercelAiGateway = p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isNonStandard =
isNvidia ||
isCerebras ||
isXai ||
isTogether ||
isChutes ||
isDeepSeek ||
isZai ||
isMoonshot ||
isOpencode ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isVercelAiGateway ||
isAntLing
return !isNonStandard
}
const detectSupportsUsageInStreaming = (): boolean => true
const detectSupportsStrictMode = (provider: string, baseURL: string | undefined): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isMoonshot = p === "moonshotai" || p === "moonshotai-cn" || url.includes("api.moonshot.")
const isTogether = p === "togetherai" || p === "together" || url.includes("api.together.")
const isCloudflareAiGateway = p === "cloudflare-ai-gateway" || url.includes("gateway.ai.cloudflare.com")
const isNvidia = p === "nvidia" || url.includes("integrate.api.nvidia.com")
return !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia
}
const detectZaiToolStream = (
provider: string,
baseURL: string | undefined,
modelID: string,
): boolean => {
const p = provider.toLowerCase()
const url = (baseURL ?? "").toLowerCase()
const isZai =
p === "zai" ||
p === "zai-coding-plan" ||
p === "zhipuai" ||
p === "zhipuai-coding-plan" ||
url.includes("api.z.ai") ||
url.includes("open.bigmodel.cn")
if (!isZai) return false
const id = modelID.toLowerCase()
if (id === "glm-4.5" || id === "glm-4.5-air" || id === "glm-4.5-flash" || id === "glm-4.5v") return false
return true
}
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
// even when no explicit `providerOptions.store` was supplied, mirroring the
// native OpenAI Chat default. Non-standard providers omit `store` entirely.
...(supportsStore && options.store === undefined ? { store: false } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
}
@@ -684,19 +551,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const provider = String(request.model.provider)
const baseURL = request.model.route.endpoint.baseURL
const detectedMaxTokensField = detectMaxTokensField(provider, baseURL)
const maxTokensField = request.model.compatibility?.maxTokensField ?? detectedMaxTokensField
const supportsStore = request.model.compatibility?.supportsStore ?? detectSupportsStore(provider, baseURL)
const supportsUsageInStreaming =
request.model.compatibility?.supportsUsageInStreaming ?? detectSupportsUsageInStreaming()
const supportsStrictMode = request.model.compatibility?.supportsStrictMode ?? detectSupportsStrictMode(provider, baseURL)
const zaiToolStream =
request.model.compatibility?.zaiToolStream ??
detectZaiToolStream(provider, baseURL, request.model.id)
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
const hasHistory = hasToolHistory(request.messages)
const hasActiveTools = request.tools.length > 0
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
@@ -710,13 +566,11 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
supportsStrictMode,
),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
@@ -726,7 +580,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
presence_penalty: generation?.presencePenalty,
seed: generation?.seed,
stop: generation?.stop,
...lowerOptions(request, supportsStore),
...lowerOptions(request),
}
})
@@ -736,40 +590,14 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
// Streaming parsers are small state machines: every event returns a new state
// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated
// because OpenAI streams JSON arguments across multiple deltas.
const finishReasonError = (event: OpenAIChatEvent, reason: AIError["reason"]) =>
new AIError({
module: ADAPTER,
method: "stream",
body: ProviderShared.encodeJson(event),
reason,
})
const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event: OpenAIChatEvent, reason: string) {
switch (reason) {
case "error":
return yield* finishReasonError(
event,
new UnknownProviderReason({ message: "Provider reported an error (finish_reason: error)" }),
)
case "network_error":
return yield* finishReasonError(
event,
new ProviderInternalReason({ message: "Provider reported a network error (finish_reason: network_error)" }),
)
case "stop":
case "end":
return "stop" as const
case "length":
return "length" as const
case "content_filter":
return "content-filter" as const
case "function_call":
case "tool_calls":
return "tool-calls" as const
default:
return "unknown" as const
}
})
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "stop") return "stop"
if (reason === "length") return "length"
if (reason === "content_filter") return "content-filter"
if (reason === "function_call" || reason === "tool_calls") return "tool-calls"
if (reason === "error") return "error"
return "unknown"
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
@@ -882,20 +710,16 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
if (event.error) {
const body = ProviderShared.encodeJson(event)
if (event.error)
return yield* new AIError({
module: ADAPTER,
method: "stream",
body,
reason: classifyProviderFailure({
message: event.error.message,
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
})
}
const events: LLMEvent[] = []
const choice = event.choices?.[0]
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
@@ -904,11 +728,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
const rawFinishReason = choice?.finish_reason
const finishReason =
rawFinishReason
? {
normalized: yield* mapFinishReason(event, rawFinishReason),
raw: choice?.native_finish_reason ?? rawFinishReason,
}
rawFinishReason !== undefined && rawFinishReason !== null
? { normalized: mapFinishReason(rawFinishReason), raw: choice?.native_finish_reason ?? rawFinishReason }
: state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
@@ -928,11 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const
}
@@ -1004,19 +821,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
return yield* ProviderShared.eventError(ADAPTER, result.reason.message, ProviderShared.encodeJson(event))
if (ToolStream.isError(result)) return yield* result
tools = result.tools
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(...result.events)
}
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat tool call delta is missing id or name",
ProviderShared.encodeJson(event),
)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// valid calls and malformed local calls settle independently.
@@ -1039,27 +851,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
reasoningEmitted,
latestToolIndex,
nextToolIndex,
requireFinishReason: state.requireFinishReason,
},
events,
] as const
})
const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: ParserState) {
if (state.finishReason === undefined && state.requireFinishReason)
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
route: ADAPTER,
}),
})
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = []
const toolCallEvents =
state.finishReason === undefined && Object.keys(state.tools).length > 0
? (yield* ToolStream.finishAll(ADAPTER, state.tools)).events
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason
@@ -1068,7 +869,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
}
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) }
const metadata = reasoningMetadata(
state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -1082,7 +883,7 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
events.push(...toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events
})
}
// =============================================================================
// Protocol And OpenAI Route
@@ -1111,7 +912,6 @@ export const protocol = Protocol.make({
reasoningDetailsObserved: false,
reasoningEmitted: false,
nextToolIndex: 0,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step,
onHalt: finishEvents,
@@ -111,10 +111,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
request.tools.length === 0
? undefined
@@ -164,7 +162,7 @@ const HOSTED_TOOLS = {
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
@@ -1,64 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js/blob/main/src/options.ts
* Licensed under the MIT License; see partial-json.ts for the complete notice.
*/
/**
* allow partial strings like `"hello \u12` to be parsed as `"hello `
*/
export const STR = 0b000000001
/**
* allow partial numbers like `123.` to be parsed as `123`
*/
export const NUM = 0b000000010
/**
* allow partial arrays like `[1, 2,` to be parsed as `[1, 2]`
*/
export const ARR = 0b000000100
/**
* allow partial objects like `{"a": 1, "b":` to be parsed as `{"a": 1}`
*/
export const OBJ = 0b000001000
/**
* allow `nu` to be parsed as `null`
*/
export const NULL = 0b000010000
/**
* allow `tr` to be parsed as `true`, and `fa` to be parsed as `false`
*/
export const BOOL = 0b000100000
/**
* allow `Na` to be parsed as `NaN`
*/
export const NAN = 0b001000000
/**
* allow `Inf` to be parsed as `Infinity`
*/
export const INFINITY = 0b010000000
/**
* allow `-Inf` to be parsed as `-Infinity`
*/
export const _INFINITY = 0b100000000
export const INF = INFINITY | _INFINITY
export const SPECIAL = NULL | BOOL | INF | NAN
export const ATOM = STR | NUM | SPECIAL
export const COLLECTION = ARR | OBJ
export const ALL = ATOM | COLLECTION
/**
* Control what types you allow to be partially parsed.
* The default is to allow all types to be partially parsed, which in most cases is the best option.
*/
export const Allow = { STR, NUM, ARR, OBJ, NULL, BOOL, NAN, INFINITY, _INFINITY, INF, SPECIAL, ATOM, COLLECTION, ALL }
export default Allow
@@ -1,223 +0,0 @@
/*
* Adapted from partial-json by the Promplate Dev Team:
* https://github.com/promplate/partial-json-parser-js
*
* MIT License
*
* Copyright (c) 2023 Promplate Dev Team
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { Schema } from "effect"
import { Allow } from "./partial-json-options.js"
export * from "./partial-json-options.js"
export class PartialJSON extends Error {}
export class MalformedJSON extends Error {}
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
/** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */
export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown {
if (typeof jsonString !== "string") throw new TypeError(`expecting str, got ${typeof jsonString}`)
const input = jsonString.trim()
if (!input) throw new Error(`${jsonString} is empty`)
try {
return decodeJson(input)
} catch {}
return _parseJSON(input, allowPartial)
}
const _parseJSON = (jsonString: string, allow: number) => {
const length = jsonString.length
let index = 0
const markPartialJSON = (message: string): never => {
throw new PartialJSON(`${message} at position ${index}`)
}
const throwMalformedError = (message: string): never => {
throw new MalformedJSON(`${message} at position ${index}`)
}
const parseAny = (): unknown => {
skipBlank()
if (index >= length) markPartialJSON("Unexpected end of input")
if (jsonString[index] === '"') return parseStr()
if (jsonString[index] === "{") return parseObj()
if (jsonString[index] === "[") return parseArr()
if (
jsonString.substring(index, index + 4) === "null" ||
(Allow.NULL & allow && length - index < 4 && "null".startsWith(jsonString.substring(index)))
) {
index += 4
return null
}
if (
jsonString.substring(index, index + 4) === "true" ||
(Allow.BOOL & allow && length - index < 4 && "true".startsWith(jsonString.substring(index)))
) {
index += 4
return true
}
if (
jsonString.substring(index, index + 5) === "false" ||
(Allow.BOOL & allow && length - index < 5 && "false".startsWith(jsonString.substring(index)))
) {
index += 5
return false
}
if (
jsonString.substring(index, index + 8) === "Infinity" ||
(Allow.INFINITY & allow && length - index < 8 && "Infinity".startsWith(jsonString.substring(index)))
) {
index += 8
return Infinity
}
if (
jsonString.substring(index, index + 9) === "-Infinity" ||
(Allow._INFINITY & allow &&
1 < length - index &&
length - index < 9 &&
"-Infinity".startsWith(jsonString.substring(index)))
) {
index += 9
return -Infinity
}
if (
jsonString.substring(index, index + 3) === "NaN" ||
(Allow.NAN & allow && length - index < 3 && "NaN".startsWith(jsonString.substring(index)))
) {
index += 3
return NaN
}
return parseNum()
}
const parseStr = (): string => {
const start = index
let escape = false
index++
while (index < length && (jsonString[index] !== '"' || (escape && jsonString[index - 1] === "\\"))) {
escape = jsonString[index] === "\\" ? !escape : false
index++
}
if (jsonString.charAt(index) === '"') {
try {
return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string
} catch (error) {
throwMalformedError(String(error))
}
}
if (Allow.STR & allow) {
try {
return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string
} catch {
return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string
}
}
return markPartialJSON("Unterminated string literal")
}
const parseObj = (): Record<string, unknown> => {
index++
skipBlank()
const object: Record<string, unknown> = {}
try {
while (jsonString[index] !== "}") {
skipBlank()
if (index >= length && Allow.OBJ & allow) return object
const key = parseStr()
skipBlank()
index++
try {
object[key] = parseAny()
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
}
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.OBJ & allow) return object
return markPartialJSON("Expected '}' at end of object")
}
index++
return object
}
const parseArr = (): unknown[] => {
index++
const array: unknown[] = []
try {
while (jsonString[index] !== "]") {
array.push(parseAny())
skipBlank()
if (jsonString[index] === ",") index++
}
} catch {
if (Allow.ARR & allow) return array
return markPartialJSON("Expected ']' at end of array")
}
index++
return array
}
const parseNum = (): unknown => {
if (index === 0) {
if (jsonString === "-") throwMalformedError("Not sure what '-' is")
try {
return decodeJson(jsonString)
} catch (error) {
if (Allow.NUM & allow) {
try {
return decodeJson(jsonString.substring(0, jsonString.lastIndexOf("e")))
} catch {}
}
throwMalformedError(String(error))
}
}
const start = index
if (jsonString[index] === "-") index++
while (jsonString[index] && !",]}".includes(jsonString[index])) index++
if (index === length && !(Allow.NUM & allow)) markPartialJSON("Unterminated number literal")
try {
return decodeJson(jsonString.substring(start, index))
} catch (error) {
if (jsonString.substring(start, index) === "-") markPartialJSON("Not sure what '-' is")
try {
return decodeJson(jsonString.substring(start, jsonString.lastIndexOf("e")))
} catch {
throwMalformedError(String(error))
}
}
}
const skipBlank = () => {
while (index < length && " \n\r\t".includes(jsonString[index])) index++
}
return parseAny()
}
export const parse = parseJSON
@@ -1,10 +1,8 @@
import { Effect, Option } from "effect"
import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
type StreamKey = string | number
const parsePartialInput = Option.liftThrowable(parse)
/**
* One pending streamed tool call. Providers emit the tool identity and JSON
@@ -59,15 +57,12 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) => {
const input = parsePartialInput(tool.input)
return LLMEvent.toolInputDelta({
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
...(Option.isSome(input) ? { input: input.value } : {}),
})
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
+6 -24
View File
@@ -321,30 +321,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
const stream = Stream.suspend(() => {
let state = protocol.stream.initial(request)
const parsed = events.pipe(
Stream.mapEffect((event) =>
protocol.stream.step(state, event).pipe(
Effect.map(([next, output]) => {
state = next
return output
}),
),
),
Stream.flatMap(Stream.fromIterable),
)
const onHalt = protocol.stream.onHalt
return onHalt
? parsed.pipe(
Stream.concat(
Stream.suspend(() =>
Stream.unwrap(onHalt(state).pipe(Effect.map(Stream.fromIterable))),
),
),
)
: parsed
}).pipe(
const stream = events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
)
+2 -2
View File
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean
/** Optional effectful flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
/** Optional flush emitted when the framed stream ends. */
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
}
/**
-2
View File
@@ -152,8 +152,6 @@ export const ToolInputDelta = Schema.Struct({
id: ToolCallID,
name: Schema.String,
text: Schema.String,
/** Best-effort parse of all input fragments received through this delta. */
input: Schema.optional(Schema.Unknown),
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
-5
View File
@@ -155,11 +155,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
+1 -1
View File
@@ -66,7 +66,7 @@ describe("request option precedence", () => {
expect(prepared.body).toMatchObject({
model: "gpt-4o-mini",
stream: true,
max_completion_tokens: 30,
max_tokens: 30,
temperature: 0.5,
top_p: 0.9,
frequency_penalty: 0.25,
@@ -7,13 +7,7 @@
"route": "cloudflare-workers-ai",
"transport": "http",
"model": "@cf/openai/gpt-oss-20b",
"tags": [
"prefix:cloudflare-workers-ai",
"provider:cloudflare-workers-ai",
"tool",
"tool-call",
"golden"
]
"tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"]
},
"interactions": [
{
@@ -24,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\": \"@cf/openai/gpt-oss-20b\", \"messages\": [{\"role\": \"system\", \"content\": \"Call tools exactly as requested.\"}, {\"role\": \"user\", \"content\": \"Call get_weather with city exactly Paris.\"}], \"tools\": [{\"type\": \"function\", \"function\": {\"name\": \"get_weather\", \"description\": \"Get current weather for a city.\", \"parameters\": {\"type\": \"object\", \"properties\": {\"city\": {\"type\": \"string\"}}, \"required\": [\"city\"], \"additionalProperties\": false}, \"strict\": false}}], \"tool_choice\": {\"type\": \"function\", \"function\": {\"name\": \"get_weather\"}}, \"stream\": true, \"stream_options\": {\"include_usage\": true}, \"max_tokens\": 120, \"temperature\": 0}"
"body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}"
},
"response": {
"status": 200,
@@ -35,4 +29,4 @@
}
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Allow, MalformedJSON, PartialJSON, parse } from "../src/protocols/utils/partial-json.js"
describe("partial JSON", () => {
test("parses complete JSON", () => {
expect(parse('{"key":"value","items":[1,true,null]}')).toEqual({
key: "value",
items: [1, true, null],
})
const object = parse('{"__proto__":{"safe":true}}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
})
test("parses partial strings", () => {
expect(parse('"hello')).toBe("hello")
expect(parse('"hello \\u12')).toBe("hello ")
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
expect(parse('{"key":"', Allow.OBJ)).toEqual({})
expect(parse('{"key":"', Allow.OBJ | Allow.STR)).toEqual({ key: "" })
})
test("parses partial literals and numbers", () => {
expect(parse("nu", Allow.NULL)).toBeNull()
expect(parse("tr", Allow.BOOL)).toBe(true)
expect(parse("fa", Allow.BOOL)).toBe(false)
expect(parse("1e", Allow.NUM)).toBe(1)
})
test("distinguishes disallowed partial values from malformed values", () => {
expect(() => parse("[", Allow.STR)).toThrow(PartialJSON)
expect(() => parse("n", ~Allow.NULL)).toThrow(MalformedJSON)
})
test("rejects empty input", () => {
expect(() => parse(" ")).toThrow("is empty")
})
})
@@ -18,15 +18,6 @@ const opus48 = AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id: "claude-opus-4-8" })
const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
compileRequest(
LLM.request({
model,
messages: [Message.assistant([{ type: "reasoning", text: "unsigned reasoning" }])],
cache: "none",
}),
)
const request = LLM.request({
id: "req_1",
model,
@@ -573,65 +564,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("demotes unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("infers empty-signature compatibility across Kimi providers", () =>
Effect.gen(function* () {
const coding = AnthropicMessages.route.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://compatible.test/v1/" },
auth: Auth.header("x-api-key", "test"),
})
const moonshot = AnthropicMessages.route
.with({
provider: "moonshotai",
endpoint: { baseURL: "https://api.moonshot.ai/anthropic" },
auth: Auth.bearer("test"),
})
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
expect(codingPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
expect(moonshotPrepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
},
])
}),
)
it.effect("lets an explicit signature requirement override inference", () =>
Effect.gen(function* () {
const compatible = AnthropicMessages.route
.with({
provider: "kimi-for-coding",
endpoint: { baseURL: "https://api.kimi.com/coding/v1/" },
auth: Auth.header("x-api-key", "test"),
})
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
])
}),
)
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1128,14 +1060,8 @@ describe("Anthropic Messages route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -475,14 +475,8 @@ describe("Bedrock Converse route", () => {
])
const events = response.events.filter((event) => event.type === "tool-input-delta")
expect(events).toEqual([
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
])
expect(response.events.at(-1)).toMatchObject({
type: "finish",
@@ -6,7 +6,6 @@ import { AmazonBedrockMantle } from "../../src/providers.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
const credentials = {
@@ -72,9 +71,7 @@ describe("Amazon Bedrock Mantle provider", () => {
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond(sseEvents({ choices: [{ delta: {}, finish_reason: "stop" }] }), {
headers: { "content-type": "text/event-stream" },
})
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
-48
View File
@@ -906,54 +906,6 @@ describe("Gemini route", () => {
}),
)
it.effect("ignores unknown response parts", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "Hello " },
{ executableCode: { language: "PYTHON", code: "print('ignored')" } },
{ text: "world" },
],
},
finishReason: "STOP",
},
],
}),
),
),
)
expect(response.text).toBe("Hello world")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
}),
)
it.effect("rejects malformed recognized response parts", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }],
}),
),
),
Effect.flip,
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
Effect.gen(function* () {
const body = sseEvents({
+7 -27
View File
@@ -47,7 +47,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are concise." },
@@ -55,8 +55,7 @@ describe("OpenAI Chat route", () => {
],
stream: true,
stream_options: { include_usage: true },
store: false,
max_completion_tokens: 20,
max_tokens: 20,
temperature: 0,
})
}),
@@ -326,7 +325,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4o-mini",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -346,7 +345,6 @@ describe("OpenAI Chat route", () => {
tools: [],
stream: true,
stream_options: { include_usage: true },
store: false,
})
}),
)
@@ -1164,14 +1162,8 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -1251,11 +1243,6 @@ describe("OpenAI Chat route", () => {
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ finish_reason: "tool_calls" }],
})
}),
)
@@ -1269,7 +1256,6 @@ describe("OpenAI Chat route", () => {
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
)
const input = LLMRequest.update(request, {
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
@@ -1277,14 +1263,8 @@ describe("OpenAI Chat route", () => {
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
@@ -70,7 +70,7 @@ describe("OpenAI-compatible Chat route", () => {
baseURL: "https://api.deepseek.test/v1/",
query: { "api-version": "2026-01-01" },
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -79,7 +79,7 @@ describe("OpenAI-compatible Chat route", () => {
tools: [
{
type: "function",
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" }, strict: false },
function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } },
},
],
tool_choice: "required",
@@ -130,7 +130,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () {
const prepared = yield* compileRequest(request)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "system", content: "You are concise." },
@@ -158,29 +158,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("enables ZAI tool streaming except for GLM 4.5 models", () =>
Effect.gen(function* () {
const prepare = (provider: string, baseURL: string, id: string) =>
compileRequest(
LLM.request({
model: OpenAICompatibleChat.route.with({ provider, endpoint: { baseURL } }).model({ id }),
prompt: "Use a tool.",
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
}),
)
const current = yield* prepare("zai", "https://api.z.ai/api/paas/v4", "glm-4.7")
expect(current.body).toMatchObject({ tool_stream: true })
const legacy = yield* Effect.all(
["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"].map((id) =>
prepare("zhipuai", "https://open.bigmodel.cn/api/paas/v4", id),
),
)
legacy.forEach((item) => expect(item.body).not.toHaveProperty("tool_stream"))
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -203,7 +180,7 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "deepseek-chat",
messages: [
{ role: "user", content: "What is the weather?" },
@@ -227,7 +204,6 @@ describe("OpenAI-compatible Chat route", () => {
name: "lookup",
description: "Lookup data",
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
strict: false,
},
},
],
@@ -353,106 +329,13 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("rejects a stream without a required finish reason", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
message: "OpenAI Chat stream ended without finish_reason",
})
}),
)
it.effect("infers stop when finish reasons are optional", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { requireFinishReason: false } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: compatible })).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "stop" })
}),
)
it.effect("normalizes the end finish reason to stop", () =>
it.effect("treats an empty finish reason as terminal", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "end")))),
Effect.provide(fixedResponse(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "")))),
)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end" })
}),
)
it.effect("classifies provider error finish reasons", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "network_error")))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "ProviderInternal",
message: "Provider reported a network error (finish_reason: network_error)",
})
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_fixture",
choices: [{ finish_reason: "network_error" }],
})
const generic = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "error")))),
Effect.flip,
)
expect(generic.reason).toMatchObject({
_tag: "UnknownProvider",
message: "Provider reported an error (finish_reason: error)",
})
}),
)
it.effect("preserves explicit provider error events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
id: "chatcmpl_error",
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
trace_id: "trace_1",
}),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Provider disconnected", status: 502 })
expect(decodeJson(error.body ?? "")).toMatchObject({
id: "chatcmpl_error",
error: { code: 502, message: "Provider disconnected", details: { upstream: "vendor" } },
trace_id: "trace_1",
})
}),
)
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
Effect.gen(function* () {
const filtered = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
)
const future = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
)
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
expect(response.finishReason).toEqual({ normalized: "unknown", raw: "" })
}),
)
@@ -472,11 +355,6 @@ describe("OpenAI-compatible Chat route", () => {
)
expect(error.message).toContain("OpenAI Chat received content after the finish reason")
expect(error.reason._tag).toBe("InvalidProviderOutput")
if (error.reason._tag !== "InvalidProviderOutput") return
expect(decodeJson(error.reason.raw ?? "")).toMatchObject({
choices: [{ delta: { tool_calls: [{ id: "call_1" }] } }],
})
}),
)
})
@@ -132,40 +132,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("lowers canonical parallel tool control", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Read the file.",
tools: [
ToolDefinition.make({
name: "read",
description: "Read a file.",
inputSchema: { type: "object" },
}),
],
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
expect(prepared.body.parallel_tool_calls).toBe(false)
expect(prepared.body.tools).toEqual([
{
type: "function",
name: "read",
description: "Read a file.",
parameters: { type: "object" },
strict: false,
},
])
}),
)
it.effect("keeps foreign item id grammars but drops malformed ids", () =>
Effect.gen(function* () {
const model = configure({
@@ -258,31 +258,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps the canonical parallel tool setting with provider-option precedence", () =>
Effect.gen(function* () {
const disabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
}),
)
const enabled = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: false },
}),
)
const overridden = yield* compileRequest(
LLMRequest.update(request, {
toolChoice: { type: "auto", disableParallelToolUse: true },
providerOptions: { parallelToolCalls: true },
}),
)
expect(disabled.body.parallel_tool_calls).toBe(false)
expect(enabled.body.parallel_tool_calls).toBe(true)
expect(overridden.body.parallel_tool_calls).toBe(true)
}),
)
it.effect("lowers chronological system updates to developer messages in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1986,11 +1961,6 @@ describe("OpenAI Responses route", () => {
item_id: "fc_missing",
delta: '{"orphaned":true}',
},
{
type: "response.function_call_arguments.done",
item_id: "fc_missing",
arguments: '{"orphaned":true}',
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -2003,22 +1973,22 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects function argument events without the spec-required item id", () =>
it.effect("rejects function argument deltas without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.function_call_arguments.done", arguments: "{}" },
]
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain(`${event.type} is missing item_id`)
}
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
}),
)
@@ -2811,14 +2781,12 @@ describe("OpenAI Responses route", () => {
id: "call_1",
name: "lookup",
text: '{"query"',
input: {},
},
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: ':"weather"}',
input: { query: "weather" },
},
{
type: "tool-input-end",
@@ -2862,172 +2830,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query"' },
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"weather"}',
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
])
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "fc_item_1" } },
},
])
}),
)
it.effect("streams complete function arguments supplied only by the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"weather"}',
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("does not repeat function arguments already supplied by deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_item_1",
delta: '{"query":"weather"}',
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"weather"}',
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => event.type === "tool-input-delta")).toHaveLength(1)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
}),
)
it.effect("uses authoritative arguments done input without emitting a mismatched delta", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.delta",
item_id: "fc_item_1",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"final"}',
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"streamed"}',
input: { query: "streamed" },
},
])
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "final" } })
}),
)
it.effect("lets completed output item arguments override the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"output-item-done"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "output-item-done" } })
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
+1 -1
View File
@@ -101,7 +101,7 @@ describe("LLMClient tools", () => {
const messages = Reflect.get(second, "messages")
const tools = Reflect.get(second, "tools")
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
expect(Reflect.get(second, "max_tokens")).toBe(50)
expect(Reflect.get(second, "tool_choice")).toBe("auto")
expect(tools).toHaveLength(1)
expect(
+2 -43
View File
@@ -23,11 +23,9 @@ describe("ToolStream", () => {
expect(first.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
])
expect(second.events).toEqual([
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
])
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
expect(finished).toEqual({
tools: {},
events: [
@@ -38,45 +36,6 @@ describe("ToolStream", () => {
}),
)
it.effect("exposes cumulative partial string values", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: '{"query":"wea' },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events.at(-1)).toEqual({
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"wea',
input: { query: "wea" },
})
}),
)
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
ToolStream.empty<number>(),
0,
{ id: "call_1", name: "lookup", text: "x" },
"missing tool",
)
if (ToolStream.isError(result)) return yield* result
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
])
}),
)
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
Effect.gen(function* () {
const first = ToolStream.appendOrStart(
@@ -20,47 +20,45 @@ const messages = [
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
const session = {
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
}
const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
pageMessages: () => ({ items: messages }),
}
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
await mockOpenCodeServer(page, {
...fixture,
sessions: [session],
directory,
project: {
id: projectID,
worktree: directory,
canonical: directory,
vcs: "git",
name: "session-message-revert",
time: { created: 1, updated: 1 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: "session-message-revert",
projectID,
directory,
title: "Session message revert",
agent: "build",
model: { id: "test", providerID: "opencode" },
version: "dev",
time: { created: 1, updated: 4 },
},
],
pageMessages: () => ({ items: messages }),
onRevertStage: (input) => staged.push(input),
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -79,19 +77,3 @@ test("reverts directly to the selected user message", async ({ page }) => {
await expect(page.getByRole("textbox", { name: "Prompt" })).toHaveText("Second prompt")
expect(staged).toEqual([{ sessionID, messageID: "msg_second" }])
})
test("hides revert actions in a child session", async ({ page }) => {
await mockOpenCodeServer(page, {
...fixture,
sessions: [
{ ...session, id: "ses_parent", slug: "parent", title: "Parent session" },
{ ...session, parentID: "ses_parent" },
],
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Session message revert")
const message = page.locator('[data-message-id="msg_second"]')
await message.hover()
await expect(message.getByRole("button", { name: "Revert message" })).toHaveCount(0)
})
@@ -1,226 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/SessionQueueRegression"
const projectID = "proj_session_queue_regression"
const sessionID = "ses_session_queue_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
type InboxRow = {
id: string
sessionID: string
timeCreated: number
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
timeCreated: 1700000000000 + index,
type: "user",
payload: { text },
delivery: "queue",
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
data,
} as OpenCodeEvent)
}
return {
rows,
prompts,
changes,
log,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
log.push(`prompt:${String(input.body.delivery ?? "steer")}`)
const row: InboxRow = {
id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`,
sessionID: input.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof input.body.text === "string" ? input.body.text : "",
...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record<string, unknown> }),
},
delivery: input.body.delivery === "queue" ? "queue" : "steer",
}
rows.push(row)
emit("session.inbox.enqueued", {
sessionID: input.sessionID,
inboxID: row.id,
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
const row = rows[index]
if (!row) return
if (input.action === "cancel") {
rows.splice(index, 1)
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
})
},
}
}
async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>, followUpBehavior?: "queue" | "steer") {
if (followUpBehavior) {
await page.addInitScript(
(behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })),
followUpBehavior,
)
}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "session-queue-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "queue-model" },
},
sessions: [
{
id: sessionID,
slug: "session-queue-regression",
projectID,
directory,
title: "Session queue regression",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
onInboxChange: mock.onInboxChange,
events: mock.events,
})
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="composer"]')
await expectAppVisible(composer)
return {
composer,
input: composer.locator('[data-component="composer-editor"]'),
rows: page.locator('[data-component="session-queue-row"]'),
}
}
test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => {
const mock = createQueueMock([])
const view = await openSession(page, mock, "queue")
await view.input.fill("queue this follow-up")
await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer")
await view.input.press("Enter")
await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible()
await view.input.fill("steer this correction")
await view.input.press("ControlOrMeta+Enter")
await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"])
await expect(view.input).toHaveText("")
})
test("dragging reorders queued prompts", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const first = view.rows.filter({ hasText: "first queued prompt" })
const third = view.rows.filter({ hasText: "third queued prompt" })
await first.getByRole("button", { name: "Reorder queued prompt" }).hover()
await page.mouse.down()
const target = await third.boundingBox()
if (!target) throw new Error("The target queue row is not visible")
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 })
await page.mouse.up()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"second queued prompt",
"third queued prompt",
"first queued prompt",
])
expect(mock.changes).toEqual([
{ inboxID: "inb_seed_1", action: "cancel" },
{ inboxID: "inb_seed_2", action: "cancel" },
{ inboxID: "inb_seed_3", action: "cancel" },
])
})
test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"])
const view = await openSession(page, mock)
const original = view.rows.getByText("tighten the error copy", { exact: true })
await expect(original).toBeVisible()
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await view.input.fill("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
await expect(view.input).toHaveText("my in-progress draft")
expect(mock.prompts.map((prompt) => prompt.text)).toEqual([
"tighten the error copy and add a retry hint",
"tighten the error copy and add a retry hint",
"third queued prompt",
])
expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true)
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
@@ -126,8 +126,6 @@ test("routes typing to the composer unless the open terminal is focused", async
const composer = page.locator('[data-component="composer-editor"]')
const terminal = page.locator('[data-component="terminal"]')
await composer.click()
await expect(composer).toBeFocused()
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
-33
View File
@@ -174,39 +174,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", {
params: SessionParams,
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", {
params: SessionParams,
payload: JsonPayload,
success: NoContent,
}),
)
.add(
HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", {
params: SessionParams,
+1 -36
View File
@@ -35,9 +35,6 @@ export interface MockServerConfig {
fileContent?: (path: string) => unknown | Promise<unknown>
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
type MockStreamWindow = Window & {
@@ -400,39 +397,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
sessionFormReply: () => noContent,
sessionFormCancel: () => noContent,
sessionBackground: () => noContent,
sessionInbox: () =>
Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })),
sessionPrompt: (ctx) =>
Effect.sync(() => {
const body = record(ctx.payload) ? ctx.payload : {}
config.onPrompt?.({ sessionID: ctx.params.sessionID, body })
return {
data: {
id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`,
sessionID: ctx.params.sessionID,
timeCreated: Date.now(),
type: "user",
payload: {
text: typeof body.text === "string" ? body.text : "",
...(body.files === undefined ? {} : { files: body.files }),
...(body.agents === undefined ? {} : { agents: body.agents }),
...(body.skills === undefined ? {} : { skills: body.skills }),
...(body.metadata === undefined ? {} : { metadata: body.metadata }),
},
delivery: body.delivery === "queue" ? "queue" : "steer",
},
}
}),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
sessionInbox: () => Effect.succeed({ data: [] }),
sessionPermission: (ctx) => {
const permissions =
typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
-20
View File
@@ -39,26 +39,6 @@ export type ComposerSelection = {
variant?: string
}
export type ComposerDelivery = "steer" | "queue"
// Contract between the composer and the session prompt queue. The session
// owns the queue (pending inbox items); the composer only asks which delivery
// a submit should use and delegates edit confirmation while a queued prompt
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
alternate: Accessor<ComposerDelivery | undefined>
// Inbox ID of the queued prompt currently loaded in the composer for editing.
editing: Accessor<string | undefined>
confirmEdit: (delivery: ComposerDelivery) => void
cancelEdit: () => void
// Loads the first queued prompt into the composer. Returns false when the queue is empty.
editFirst: () => boolean
}
export type ComposerSession = {
id: string
directory: string
+1 -2
View File
@@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ComposerEditor } from "./editor/editor"
import { ModelSelectorPopover } from "@/providers/models/select-dialog"
import { DialogSelectModelUnpaid } from "@/providers/models/unpaid"
import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
@@ -32,7 +32,6 @@ export function Composer(props: {
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+2 -50
View File
@@ -45,7 +45,6 @@ export type ComposerEditorProps = {
modelControlsVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -178,15 +177,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit(mod ? { alternate: true } : undefined)
props.controller.submit()
}
}}
onKeyUp={updateCursor}
@@ -254,12 +248,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
</Show>
</Show>
</div>
<Show when={state.mode === "normal"}>
<ComposerEditorAlternateDelivery
controller={props.controller}
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -267,7 +255,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
accent={props.accentSubmit}
sendLabel={i18n.t("ui.promptInput.send")}
stopLabel={i18n.t("ui.promptInput.stop")}
onSubmit={() => props.controller.submit()}
onSubmit={props.controller.submit}
onStop={props.controller.stop}
/>
</div>
@@ -703,42 +691,6 @@ export function ComposerEditorPopover(props: {
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
// disappears on its own when the current turn ends.
function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) {
const i18n = useI18n()
const view = props.controller.view
const action = createMemo(() => {
const queue = view.submit.queue
if (!queue || !props.controller.canSubmit()) return undefined
if (queue.editing()) return "steer" as const
return queue.alternate()
})
return (
<Show when={action()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
<span class="hidden sm:block">
<Keybind keys={props.keybind} variant="neutral" />
</span>
</Button>
</Tooltip>
)}
</Show>
)
}
export function ComposerEditorSubmitButton(props: {
mode: ComposerMode
stopping: boolean
@@ -19,7 +19,6 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -38,8 +37,7 @@ export type ComposerEditorView = {
submit: {
stopping: Accessor<boolean>
working?: Accessor<boolean>
queue?: ComposerQueue
onSubmit: (options?: { alternate?: boolean }) => void
onSubmit: () => void
onStop: () => void
}
shell?: {
@@ -214,11 +212,6 @@ export function createComposerEditor(input: {
)
}
if (handled) return true
if (event.key === "Escape" && input.view.submit.queue?.editing()) {
event.preventDefault()
input.view.submit.queue.cancelEdit()
return true
}
const stop =
input.view.submit.working?.() &&
((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") ||
@@ -361,8 +354,8 @@ export function createComposerEditor(input: {
openShell() {
dispatch({ type: "mode.shell" })
},
submit(options?: { alternate?: boolean }) {
input.view.submit.onSubmit(options)
submit() {
input.view.submit.onSubmit()
dispatch({ type: "popover.close" })
},
stop() {
+4 -24
View File
@@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
@@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
}
export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel {
export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
@@ -80,11 +80,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
})
const stopping = createMemo(() => adapter.working() && blank())
const placeholder = () =>
composerPlaceholder(
mode(),
(key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
adapter.working() || (options?.queue?.count() ?? 0) > 0,
)
composerPlaceholder(mode(), (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never))
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -257,11 +253,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
notify: {
missingSelection: () =>
showToast({
@@ -369,18 +360,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
submit: {
stopping,
working: adapter.working,
queue: options?.queue,
onSubmit: (submitOptions) => {
const queue = options?.queue
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
if (queue?.editing()) {
queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue")
return
}
void submission.submit(new Event("submit"), submitOptions)
},
onSubmit: () => void submission.submit(new Event("submit")),
onStop: () => void submission.stop(),
},
},
@@ -12,12 +12,4 @@ describe("Composer placeholder", () => {
test("uses the command and context hint in normal mode", () => {
expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@")
})
test("uses the follow-up copy while a turn runs or prompts are queued", () => {
expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@")
})
test("keeps the shell placeholder while a turn runs", () => {
expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status")
})
})
-2
View File
@@ -1,9 +1,7 @@
export function composerPlaceholder(
mode: "normal" | "shell",
t: (key: string, params?: Record<string, string>) => string,
followUp?: boolean,
) {
if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" })
if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" })
return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
}
+31 -36
View File
@@ -5,7 +5,7 @@ import type { Accessor } from "solid-js"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
@@ -21,7 +21,7 @@ type ComposerSubmission = {
text: string
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: ComposerDelivery
delivery: "steer"
}
type ComposerSubmitInput = {
@@ -33,7 +33,6 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -46,7 +45,7 @@ type ComposerSubmitInput = {
}
export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
const submit = async (event: globalThis.Event) => {
event.preventDefault()
const submission = createComposerSubmission({
@@ -57,7 +56,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
const value = readSubmission(input, submission.prompt, submission.context)
if (!value) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -114,10 +113,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
void sendCommand(session, value, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
return
@@ -161,7 +157,6 @@ function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
@@ -200,7 +195,7 @@ function readSubmission(
model: { modelID: model.id, providerID: model.provider.id },
variant,
},
delivery: input.delivery?.(alternate) ?? "steer",
delivery: "steer",
}
}
@@ -279,8 +274,15 @@ async function sendCommand(
const request = await buildSubmissionRequest(session, value)
await session.api.command({
sessionID: session.id,
id: value.id,
command: command.command,
text: command.arguments,
arguments: command.arguments,
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
skills: request.skills,
@@ -290,30 +292,23 @@ async function sendCommand(
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
const admission = {
-13
View File
@@ -677,14 +677,6 @@ export const dict = {
"session.background.subagent.one": "{{count}} subagent",
"session.background.subagent.other": "{{count}} subagents",
"command.session.background": "Move to background",
"session.queue.count.one": "{{count}} queued",
"session.queue.count.other": "{{count}} queued",
"session.queue.steer": "Steer",
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments": "+ attachments",
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
@@ -969,11 +961,6 @@ export const dict = {
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
"settings.general.row.followUpBehavior.title": "Follow-up behavior",
"settings.general.row.followUpBehavior.description":
"Choose whether to queue follow-ups or steer the current turn. Use {{keybind}} to switch.",
"settings.general.row.followUpBehavior.queue": "Queue",
"settings.general.row.followUpBehavior.steer": "Steer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -193,9 +193,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const openTerminal = () => {
actions.session.layout.view().terminal.open()
if (terminal.all().length > 0) terminal.new({ focus: true })
if (terminal.all().length === 0) terminal.requestFocus()
actions.session.layout.view().terminal.open()
}
const closeTerminal = () => {
@@ -361,8 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
actions.session.layout.view().terminal.close()
return
}
actions.session.layout.view().terminal.open()
terminal.requestFocus(terminal.active())
actions.session.layout.view().terminal.open()
},
}),
viewCommand({
+1 -5
View File
@@ -37,11 +37,7 @@ export function createActiveComposerAdapter(input: {
current: () => data.session.get(id),
admitted: (messageID) => data.session.input.has(id, messageID) || !!data.session.message.get(id, messageID),
}),
interrupt: () =>
server.api.session
.interrupt({ sessionID: id, continue: true })
.then(() => undefined)
.catch(() => undefined),
interrupt: () => server.api.session.interrupt({ sessionID: id, continue: true }).catch(() => undefined),
}
return adapter
}
@@ -1,186 +0,0 @@
import { createMemo, For, Show } from "solid-js"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import type { SessionQueueView } from "./queue"
// Pullout above the composer listing the prompts queued behind the current
// turn. The panel slides under the composer card (negative margin, opaque
// composer background) so the two read as one attached surface.
export function SessionQueuePanel(props: { queue: SessionQueueView }) {
const language = useLanguage()
const count = () => props.queue.rows().length
let listRef!: HTMLDivElement
return (
<Show when={count() > 0}>
<div
data-component="session-queue-panel"
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
>
<Show when={count() > 3}>
<div class="px-1.5 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
{language.plural("session.queue.count", count())}
</div>
</Show>
<DragDropProvider
sensors={(defaults) => [
...defaults.filter((sensor) => sensor !== PointerSensor),
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
}),
]}
modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
plugins={(defaults) => [
...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
Feedback.configure({ dropAnimation: null }),
]}
onDragEnd={(event) => {
const source = event.operation.source
if (event.canceled || !isSortable(source)) return
if (source.initialIndex === source.index) return
void props.queue.reorder(
arrayMove(
props.queue.rows().map((row) => row.id),
source.initialIndex,
source.index,
),
)
}}
>
{/* Keyed on row IDs so store updates move row elements instead of
remounting them, which would kill an in-flight drag. */}
<div
ref={listRef}
class="flex flex-col gap-px"
classList={{ "max-h-[131px] overflow-y-auto": count() > 3 }}
>
<For each={props.queue.rows().map((row) => row.id)}>
{(id, index) => <SessionQueueRow queue={props.queue} id={id} index={index()} />}
</For>
</div>
</DragDropProvider>
</div>
</Show>
)
}
function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) {
const language = useLanguage()
const row = createMemo(() => props.queue.rows().find((entry) => entry.id === props.id))
const editing = () => props.queue.editing() === props.id
// While the turn is stopped the queue stays parked, so the first prompt
// shows its actions without hover and its label reads Send: that is how a
// parked queue resumes.
const active = () => !props.queue.working() && props.index === 0
const sortable = useSortable({
get id() {
return props.id
},
get index() {
return props.index
},
get disabled() {
return props.queue.busy()
},
})
return (
<Show when={row()} keyed>
{(entry) => (
<div
ref={sortable.ref}
data-component="session-queue-row"
class="group/queue-row flex items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
classList={{
"bg-v2-overlay-simple-overlay-hover": editing(),
"opacity-60": sortable.isDragSource(),
}}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<button
ref={sortable.handleRef}
type="button"
class="grid shrink-0 cursor-grab touch-none grid-cols-2 gap-x-[2px] gap-y-[2.25px] p-1"
aria-label={language.t("session.queue.reorder")}
>
<For each={Array.from({ length: 6 })}>
{() => <span class="size-[2px] bg-v2-background-bg-layer-04" />}
</For>
</button>
<div class="flex min-w-0 flex-col">
<button
type="button"
data-action="session-queue-edit"
dir="auto"
disabled={props.queue.busy()}
class="max-w-full min-w-0 self-start truncate rounded-sm text-start text-[13px] font-[440] leading-[var(--line-height-compact)]"
classList={{
"text-v2-text-text-faint": editing(),
"cursor-text text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover": !editing(),
}}
onClick={() => props.queue.edit(props.id)}
>
{entry.text || (entry.attachments ? language.t("session.queue.attachments") : "")}
</button>
<Show when={entry.attachments && entry.text}>
<span class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
{language.t("session.queue.attachments")}
</span>
</Show>
</div>
</div>
<div
data-slot="session-queue-actions"
class="flex shrink-0 items-center gap-1.5"
classList={{
"opacity-0 focus-within:opacity-100 group-hover/queue-row:opacity-100 [@media(hover:none)]:opacity-100":
!active() && !editing(),
"pointer-events-none": props.queue.busy(),
}}
>
<Show when={!editing()}>
<Tooltip
placement="top"
inactive={!props.queue.working()}
value={language.t("session.queue.steerTooltip")}
>
<Button
data-action="session-queue-steer"
type="button"
size="small"
variant="ghost-muted"
icon="arrow-up"
disabled={props.queue.busy()}
class="text-v2-text-text-muted ![font-weight:530]"
onClick={() => void props.queue.steer(props.id)}
>
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
data-action="session-queue-remove"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="outline-xmark" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.remove")}
onClick={() => void props.queue.remove(props.id)}
/>
</Tooltip>
</div>
</div>
)}
</Show>
)
}
-275
View File
@@ -1,275 +0,0 @@
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionInboxInfo } from "@opencode-ai/client/promise"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerModel } from "@/composer/model"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
export type QueuedPrompt = Extract<SessionInboxInfo, { type: "user" }>
type EditStash = {
prompt: Prompt
cursor: number
mode: "normal" | "shell"
retry: ReturnType<ComposerStateTarget["retry"]["current"]>
}
export function createSessionQueue(input: {
sessionID: string
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
composer: Accessor<ComposerModel | undefined>
}) {
const data = useData()
const server = useServerSDK()
const location = useWorkspaceLocation()
const language = useLanguage()
const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false })
const queued = createMemo(() =>
data.session.pending
.list(input.sessionID)
.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"),
)
const rows = createMemo(() =>
queued().map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) > 0,
})),
)
createEffect(() => {
const editing = state.editing
if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return
setState("editing", undefined)
})
onCleanup(() => cancelEdit())
const notify = () => showToast({ title: language.t("common.requestFailed") })
const run = (work: () => Promise<unknown>) => {
setState("busy", true)
return work()
.catch(() => notify())
.finally(async () => {
await data.session.pending.sync(input.sessionID).catch(() => undefined)
setState("busy", false)
})
}
const rewrite = async (inboxIDs: string[]) => {
const pending = await server.api.session.inbox.list({ sessionID: input.sessionID })
if (pending.some((item) => item.delivery === "queue" && item.type !== "user"))
throw new Error("Queued control items block reordering")
const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue")
const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id))
if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering")
const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id)
if (changed < 0) return
// Existing inbox APIs cannot reorder rows, so replace only the changed suffix.
for (const item of ordered.slice(changed)) {
await data.session.prompt({
sessionID: input.sessionID,
text: item.payload.text,
files: item.payload.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: item.payload.agents,
skills: item.payload.skills,
metadata: item.payload.metadata,
delivery: "queue",
resume: false,
})
}
for (const item of current.slice(changed)) {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id })
}
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const reorder = (inboxIDs: string[]) => {
if (state.busy) return Promise.resolve()
return run(() => rewrite(inboxIDs))
}
const edit = (id: string) => {
if (state.busy) return false
if (state.editing?.id === id) return true
const item = queued().find((entry) => entry.id === id)
if (!item) return false
if (state.editing) cancelEdit()
const draft = input.draft.current()
setState("editing", {
id,
stash: {
prompt: clonePrompt(draft),
cursor: input.draft.cursor() ?? promptLength(draft),
mode: input.draft.mode.current(),
retry: input.draft.retry.current(),
},
})
const text = queuedPromptText(item)
input.composer()?.dispatch({ type: "mode.normal" })
input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
input.composer()?.restoreFocus(text.length)
return true
}
const cancelEdit = () => {
const editing = state.editing
if (!editing) return
setState("editing", undefined)
// Mode first, then prompt, then retry: mode and prompt writes both clear
// the retry marker.
input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" })
input.draft.set(editing.stash.prompt, editing.stash.cursor)
if (editing.stash.retry) input.draft.retry.set(editing.stash.retry)
input.composer()?.restoreFocus(editing.stash.cursor)
}
const confirmEdit = (delivery: ComposerDelivery) => {
const editing = state.editing
if (!editing || state.busy) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image")
if (pristine && delivery === "queue") return cancelEdit()
const inboxIDs = queued().map((entry) => entry.id)
void run(async () => {
const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
...replacement,
delivery,
...(delivery === "queue" ? { resume: false } : {}),
})
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id })
cancelEdit()
if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id)))
})
}
const editFirst = () => {
const first = queued()[0]
if (!first) return false
return edit(first.id)
}
return {
count: () => queued().length,
delivery: () => (input.working() ? input.behavior() : "steer"),
alternate: () => {
if (state.editing) return "steer"
if (!input.working()) return undefined
return input.behavior() === "queue" ? "steer" : "queue"
},
editing: () => state.editing?.id,
confirmEdit,
cancelEdit,
editFirst,
rows,
busy: () => state.busy,
working: input.working,
steer,
remove,
edit,
reorder,
}
}
export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptText(item: QueuedPrompt) {
const display = item.payload.metadata?.["displayText"]
return typeof display === "string" && display.length > 0 ? display : item.payload.text
}
// Confirming an edit submits the current composer content as the replacement:
// mentions and images added during the edit are parsed like a normal
// submission, the original's stored attachments are preserved, and the
// review-comment notes appended to the original's model-visible text survive.
// Ambient composer context (open review comments) stays out: it belongs to
// the next fresh prompt, not to a queued edit.
async function editedPromptInput(
sessionID: string,
directory: string,
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
) {
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
)
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
const mention = (value: { start: number; end: number; text: string } | undefined) => {
if (!value) return undefined
const start = text.indexOf(value.text)
if (start < 0) return undefined
return { text: value.text, start, end: start + value.text.length }
}
// Structured mentions degrade to plain text in the editor, so an original
// agent or skill reference survives the edit as long as its mention text
// still appears; newly typed structured mentions come from the request.
const agents = [
...(payload?.agents?.filter(
(agent) =>
agent.mention &&
text.includes(agent.mention.text) &&
!request.agents.some((entry) => entry.name === agent.name),
) ?? []),
...request.agents,
]
const skills = [
...(payload?.skills?.filter(
(skill) =>
skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id),
) ?? []),
...request.skills,
]
return {
sessionID,
text: request.text + notes,
files: [
...(payload?.files?.map((file) => ({
uri: `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: mention(file.mention),
})) ?? []),
...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
],
agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })),
skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })),
metadata: { ...payload?.metadata, displayText: request.displayText },
}
}
+4 -36
View File
@@ -6,7 +6,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate } from "@solidjs/router"
import { createEffect, on, onMount } from "solid-js"
import { Composer } from "@/composer/composer"
import { createComposerModel, type ComposerModel } from "@/composer/model"
import { createComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
@@ -27,11 +27,8 @@ import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
import { createSessionComposerRegionController } from "./session-composer-region-controller"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
import { SessionQueuePanel } from "./queue-panel"
import { resolveSessionComposerSelection } from "./selection"
import { createSessionRequestModel } from "../requests/model"
import { useSettings } from "@/settings/model"
export function createActiveSessionRegion(input: {
session: SessionModel
@@ -159,7 +156,6 @@ export function createActiveSessionRegion(input: {
session: input.session,
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
useComposerCommands()
useSessionCommands({
session: input.session,
@@ -182,13 +178,7 @@ export function createActiveSessionRegion(input: {
return {
actions: {
timeline: {
get revert() {
if (input.session.data.isChild()) return
return revertMessage
},
openAttachment,
} satisfies SessionUserActions,
timeline: { revert: ({ messageID }) => revert.to(messageID), openAttachment } satisfies SessionUserActions,
},
region: {
centered: input.screen.centered,
@@ -219,7 +209,6 @@ export function ActiveSessionComposerRegion(props: {
accentSubmit: boolean
onResponseSubmit: () => void
}) {
const settings = useSettings()
const region = createSessionComposerRegionController({
state: props.model.region.state,
parentID: props.session.data.parentID,
@@ -235,32 +224,11 @@ export function ActiveSessionComposerRegion(props: {
submitted: props.model.submitted,
setEditor: props.model.input.setPromptRef,
})
let composer: ComposerModel | undefined
const queue = createSessionQueue({
sessionID: requireSessionID(props.session),
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
composer: () => composer,
})
composer = createComposerModel(adapter, { queue })
const composer = createComposerModel(adapter)
return (
<SessionComposerRegion
controller={region}
composer={
<div class="relative">
<SessionQueuePanel queue={queue} />
<div class="relative z-10">
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
</div>
</div>
}
composer={<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />}
/>
)
}
function requireSessionID(session: SessionModel) {
const id = session.identity.params.id
if (!id) throw new Error("Active Composer requires a Session ID")
return id
}
-22
View File
@@ -55,28 +55,6 @@ export function createSessionRevert(input: {
await server.api.session.interrupt({ sessionID }).catch(() => undefined)
}
if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return
// Reverting to a previous prompt discards the pending queue (and pending
// steers): they were written against the history being rewound. Cancel
// the authoritative inbox merged with the local snapshot, fire-and-forget
// so a slow request cannot delay restoring the composer. The cutoff keeps
// the asynchronous sweep away from prompts admitted after the revert; an
// old admission still in flight when the list is fetched can survive it,
// and fully closing that race needs a server-side revert-discards-inbox
// rule.
const cutoff = Date.now()
const local = data.session.pending
.list(sessionID)
.filter((item) => item.type === "user")
.map((item) => item.id)
void server.api.session.inbox
.list({ sessionID })
.then((rows) => rows.filter((row) => row.type === "user" && row.timeCreated <= cutoff).map((row) => row.id))
.catch(() => [])
.then((authoritative) => {
new Set([...local, ...authoritative]).forEach(
(inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined),
)
})
restore(target, message)
owner.run(() => input.setActiveMessage(previous))
}
+1 -37
View File
@@ -7,13 +7,7 @@ import { TextInput } from "@opencode-ai/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useUpdaterAction } from "@/shell/updates/action"
import {
type FollowUpBehavior,
type TerminalPlacement,
type WorkspaceDefaultDestination,
useSettings,
} from "@/settings/model"
import { formatKeybind } from "@/shell/commands/command"
import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -154,35 +148,6 @@ const TerminalPlacementSetting: Component = () => {
)
}
const FollowUpBehaviorSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: FollowUpBehavior; label: string }[] => [
{ value: "queue", label: language.t("settings.general.row.followUpBehavior.queue") },
{ value: "steer", label: language.t("settings.general.row.followUpBehavior.steer") },
])
return (
<SettingsRow
title={language.t("settings.general.row.followUpBehavior.title")}
description={language.t("settings.general.row.followUpBehavior.description", {
keybind: formatKeybind("mod+enter", language.t),
})}
>
<Select
data-action="settings-follow-up-behavior"
options={options()}
current={options().find((option) => option.value === settings.general.followUpBehavior())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.general.setFollowUpBehavior(option.value)}
/>
</SettingsRow>
)
}
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
@@ -329,7 +294,6 @@ export const SettingsGeneral: Component<{
<ShellSetting controller={shell} />
<TerminalPlacementSetting />
<FollowUpBehaviorSetting />
<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
-7
View File
@@ -7,7 +7,6 @@ import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export type TerminalPlacement = "side" | "bottom"
export type FollowUpBehavior = "queue" | "steer"
export interface NotificationSettings {
agent: boolean
@@ -40,7 +39,6 @@ export interface Settings {
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
terminalPlacement: TerminalPlacement
followUpBehavior: FollowUpBehavior
}
appearance: {
fontSize: number
@@ -128,7 +126,6 @@ const defaultSettings: Settings = {
showCustomAgents: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: {
fontSize: 14,
@@ -259,10 +256,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTerminalPlacement(value: TerminalPlacement) {
setStore("general", "terminalPlacement", value)
},
followUpBehavior: withFallback(() => store.general?.followUpBehavior, defaultSettings.general.followUpBehavior),
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
},
visibility: {
fileTree: showFileTree,
-6
View File
@@ -76,7 +76,6 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
@@ -346,11 +345,6 @@ export async function streamTurn(input: {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
})
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
}
if (control.cancelled) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
if (!started) {
+2 -2
View File
@@ -326,7 +326,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
@@ -378,8 +377,9 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
return client.session.command(
{
sessionID: session.id,
id: prompt.start.id,
command: prompt.command.name,
text: prompt.slash?.args ?? "",
arguments: prompt.slash?.args,
files: prompt.files,
delivery: "steer",
},
+2 -2
View File
@@ -1,5 +1,5 @@
export type Policy = boolean | "notify"
export type Action = "none" | "notify" | "upgrade"
export type Action = "none" | "upgrade"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -12,7 +12,7 @@ export function action(current: string, latest: string, policy: Policy): Action
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return policy === "notify" ? "notify" : "upgrade"
return "upgrade"
}
function parseReleaseVersion(input: string) {
+2 -6
View File
@@ -12,12 +12,8 @@ describe("updater", () => {
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
})
test("skips when autoupdate is disabled", () => {
-2
View File
@@ -162,8 +162,6 @@ export const layer = Layer.effect(
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
+1 -2
View File
@@ -601,7 +601,6 @@ describe("acp event behavior", () => {
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
return true
},
})
const result = streamTurn({
@@ -625,7 +624,7 @@ describe("acp event behavior", () => {
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
control.cancelled = true
control.admission.abort()
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
const response = await withTimeout(result, "cancelled turn did not terminate")
expect(response).toMatchObject({ stopReason: "cancelled" })
-39
View File
@@ -121,42 +121,3 @@ test("acp prompt resolves after ordered turn updates", async () => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
})
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
fetch(request) {
if (new URL(request.url).pathname !== "/api/event") return new Response(null, { status: 404 })
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "server.connected", data: {} })}\n\n`))
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
try {
const response = await streamTurn({
client: OpenCode.make({ baseUrl: server.url.toString() }),
connection: {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
},
sessionID: "ses_test",
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
} finally {
await server.stop(true)
}
})
+1
View File
@@ -87,6 +87,7 @@ export const planAgent = {
export const reviewCommand = {
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
export const verifySkill = {
+9 -2
View File
@@ -12,7 +12,13 @@ describe("acp service prompt routing and usage", () => {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
})
return Response.json({ data: {} })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/skill") {
const id = requestID(request)
@@ -59,8 +65,9 @@ describe("acp service prompt routing and usage", () => {
const skill = fixture.requests.find((request) => request.path === "/api/session/ses_routes/skill")
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
id: expect.any(String),
command: "review",
text: "now",
arguments: "now",
files: [],
delivery: "steer",
})
+3 -4
View File
@@ -20,7 +20,7 @@ type FixtureOptions = {
readonly onInterrupt?: (input: {
readonly sessionID: string
readonly send: (event: unknown) => void
}) => boolean | Promise<boolean>
}) => void | Promise<void>
readonly onPermissionReply?: (input: {
readonly sessionID: string
readonly requestID: string
@@ -152,9 +152,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
const interrupt = /^\/api\/session\/([^/]+)\/interrupt$/.exec(url.pathname)
if (interrupt?.[1]) {
const interrupted =
(await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })) ?? false
return Response.json({ interrupted })
await options.onInterrupt?.({ sessionID: decodeURIComponent(interrupt[1]), send })
return new Response(null, { status: 204 })
}
return new Response(null, { status: 404 })
+12 -22
View File
@@ -255,14 +255,18 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
export type SessionCommandInput = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
readonly text: string
readonly arguments?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type SessionCommandOutput = void
export type SessionCommandOutput = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -998,7 +1002,7 @@ export type SessionLogOutput =
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
export type SessionInterruptInput = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type SessionInterruptOutput = { readonly interrupted: boolean }
export type SessionInterruptOutput = void
export type SessionInterruptOperation<E = never> = (
input: SessionInterruptInput,
) => Effect.Effect<SessionInterruptOutput, E>
@@ -1108,7 +1112,11 @@ export interface ModelApi<E = never> {
readonly default: ModelDefaultOperation<E>
}
export type GenerateTextInput = { readonly prompt: string; readonly model?: Model.Ref | undefined }
export type GenerateTextInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly prompt: string
readonly model?: Model.Ref | undefined
}
export type GenerateTextOutput = { readonly text: string }
export type GenerateTextOperation<E = never> = (input: GenerateTextInput) => Effect.Effect<GenerateTextOutput, E>
@@ -1691,23 +1699,6 @@ export interface WorktreeApi<E = never> {
readonly refresh: WorktreeRefreshOperation<E>
}
export type WorkspaceCreateInput = { readonly id?: Workspace.ID | undefined; readonly provider: string }
export type WorkspaceCreateOutput = Workspace.ID
export type WorkspaceCreateOperation<E = never> = (
input: WorkspaceCreateInput,
) => Effect.Effect<WorkspaceCreateOutput, E>
export type WorkspaceDestroyInput = { readonly workspaceID: Workspace.ID }
export type WorkspaceDestroyOutput = Workspace.DestroyResult
export type WorkspaceDestroyOperation<E = never> = (
input: WorkspaceDestroyInput,
) => Effect.Effect<WorkspaceDestroyOutput, E>
export interface WorkspaceApi<E = never> {
readonly create: WorkspaceCreateOperation<E>
readonly destroy: WorkspaceDestroyOperation<E>
}
export type VcsGetInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1825,7 +1816,6 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly workspace: WorkspaceApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+13 -26
View File
@@ -214,10 +214,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -444,14 +440,21 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
}).pipe(Effect.mapError(mapClientError)),
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -721,7 +724,10 @@ const adaptGroupModel = (raw: RawClient["server.model"]) => ({
const EndpointGenerateText = (raw: RawClient["server.generate"]) => (input: GenerateTextInput) =>
preserveEffect<GenerateTextOutput>()(
raw["generate.text"]({ payload: { prompt: input["prompt"], model: input["model"] } }).pipe(
raw["generate.text"]({
query: { location: input["location"] },
payload: { prompt: input["prompt"], model: input["model"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -1272,24 +1278,6 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
refresh: EndpointWorktreeRefresh(raw),
})
const EndpointWorkspaceCreate = (raw: RawClient["server.workspace"]) => (input: WorkspaceCreateInput) =>
preserveEffect<WorkspaceCreateOutput>()(
raw["workspace.create"]({ payload: { id: input["id"], provider: input["provider"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointWorkspaceDestroy = (raw: RawClient["server.workspace"]) => (input: WorkspaceDestroyInput) =>
preserveEffect<WorkspaceDestroyOutput>()(
raw["workspace.destroy"]({ params: { workspaceID: input["workspaceID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorkspace = (raw: RawClient["server.workspace"]) => ({
create: EndpointWorkspaceCreate(raw),
destroy: EndpointWorkspaceDestroy(raw),
})
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
preserveEffect<VcsGetOutput>()(
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1380,7 +1368,6 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
workspace: adaptGroupWorkspace(raw["server.workspace"]),
vcs: adaptGroupVcs(raw["server.vcs"]),
debug: adaptGroupDebug(raw["server.debug"]),
migration: adaptGroupMigration(raw["server.migration"]),
+13 -37
View File
@@ -210,10 +210,6 @@ import type {
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
WorkspaceCreateInput,
WorkspaceCreateOutput,
WorkspaceDestroyInput,
WorkspaceDestroyOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -635,24 +631,28 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<SessionCommandOutput>(
request<{ readonly data: SessionCommandOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
id: input["id"],
command: input["command"],
text: input["text"],
arguments: input["arguments"],
agent: input["agent"],
model: input["model"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
resume: input["resume"],
},
successStatus: 204,
declaredStatuses: [404, 500, 400, 401],
empty: true,
successStatus: 200,
declaredStatuses: [409, 400, 404, 500, 401],
empty: false,
},
requestOptions,
),
).then((value) => value.data),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
@@ -880,9 +880,9 @@ export function make(options: ClientOptions) {
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
query: { continue: input["continue"] },
successStatus: 200,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: false,
empty: true,
},
requestOptions,
),
@@ -979,6 +979,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/generate`,
query: { location: input["location"] },
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
@@ -1769,31 +1770,6 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
workspace: {
create: (input: WorkspaceCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: WorkspaceCreateOutput }>(
{
method: "POST",
path: `/api/workspace`,
body: { id: input["id"], provider: input["provider"] },
successStatus: 200,
declaredStatuses: [409, 404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
destroy: (input: WorkspaceDestroyInput, requestOptions?: RequestOptions) =>
request<WorkspaceDestroyOutput>(
{
method: "DELETE",
path: `/api/workspace/${encodeURIComponent(input.workspaceID)}`,
successStatus: 200,
declaredStatuses: [500, 401, 400],
empty: false,
},
requestOptions,
),
},
vcs: {
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
request<VcsGetOutput>(
+142 -31
View File
@@ -176,8 +176,6 @@ export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?:
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
export type SessionInterruptResponse = { interrupted: boolean }
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
@@ -313,8 +311,6 @@ export type PermissionSavedInfo = { id: string; projectID: string; action: strin
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type CommandInfo = { name: string; description?: string }
export type SkillInfo = {
id: string
name: string
@@ -382,8 +378,6 @@ export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type WorkspaceDestroyResult = { destroyed: boolean }
export type VcsBranch = { current?: string; default?: string }
export type VcsFileStatus = {
@@ -397,6 +391,15 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type CommandInfo = {
name: string
template: string
description?: string
agent?: string
model?: ModelRef
subtask?: boolean
}
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -2217,13 +2220,13 @@ export type CommandNotFoundError = {
export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError"
export type CommandExecutionError = {
readonly _tag: "CommandExecutionError"
export type CommandEvaluationError = {
readonly _tag: "CommandEvaluationError"
readonly command: string
readonly message: string
}
export const isCommandExecutionError = (value: unknown): value is CommandExecutionError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandExecutionError"
export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError"
export type SkillNotFoundError = {
readonly _tag: "SkillNotFoundError"
@@ -3634,9 +3637,35 @@ export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["id"]
readonly command: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3652,10 +3681,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["command"]
readonly text: {
readonly arguments?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3671,10 +3704,60 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
}["text"]
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3690,10 +3773,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3709,10 +3796,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3728,10 +3819,14 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
readonly id?: string | null
readonly command: string
readonly text: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
@@ -3747,10 +3842,34 @@ export type SessionCommandInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
readonly id?: string | null
readonly command: string
readonly arguments?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionCommandOutput = void
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3934,7 +4053,7 @@ export type SessionInterruptInput = {
readonly continue?: { readonly continue?: boolean | undefined }["continue"]
}
export type SessionInterruptOutput = SessionInterruptResponse
export type SessionInterruptOutput = void
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -4005,6 +4124,9 @@ export type ModelDefaultOutput = {
}
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
@@ -5630,17 +5752,6 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
export type WorktreeRefreshOutput = void
export type WorkspaceCreateInput = {
readonly id?: { readonly id?: string | undefined; readonly provider: string }["id"]
readonly provider: { readonly id?: string | undefined; readonly provider: string }["provider"]
}
export type WorkspaceCreateOutput = { data: string }["data"]
export type WorkspaceDestroyInput = { readonly workspaceID: { readonly workspaceID: string }["workspaceID"] }
export type WorkspaceDestroyOutput = WorkspaceDestroyResult
export type VcsGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+3 -11
View File
@@ -172,14 +172,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.includes("/interrupt")
? Response.json({ interrupted: true })
: new Response(null, { status: 204 }),
),
)
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
@@ -209,12 +202,12 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const log = yield* client.session
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
.pipe(Stream.runCollect)
const interrupted = yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
return { page, active, created, admitted, context, log, interrupted, message }
return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
const listed = result.page.data[0]
@@ -223,7 +216,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(result.interrupted).toEqual({ interrupted: true })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
+1 -34
View File
@@ -30,7 +30,6 @@ test("exposes every standard HTTP API group", () => {
"question",
"reference",
"worktree",
"workspace",
"vcs",
"debug",
"migration",
@@ -82,21 +81,6 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("generate.text uses the locationless public contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: { text: "pong" } })
},
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
test("websearch.query uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -296,21 +280,6 @@ test("worktree methods use the global project contract", async () => {
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ destroyed: false })
},
})
expect(await client.workspace.destroy({ workspaceID: "wrk_missing" })).toEqual({ destroyed: false })
expect(request?.method).toBe("DELETE")
expect(request?.url).toBe("http://localhost:3000/api/workspace/wrk_missing")
})
test("shell list and remove use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const shell = {
@@ -547,7 +516,6 @@ test("session methods use the public HTTP contract", async () => {
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (url.includes("/interrupt")) return Response.json({ interrupted: true })
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
},
@@ -579,7 +547,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
await client.session.interrupt({ sessionID: "ses_test", continue: true })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -588,7 +556,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(interrupted).toEqual({ interrupted: true })
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced])
@@ -237,8 +237,9 @@ export default function PrivacyPolicy() {
</td>
<td>
<ul>
<li>Passing through to upstream provider to provide services</li>
<li>Not stored</li>
<li>Providing, Customizing and Improving the Services</li>
<li>Marketing the Services</li>
<li>Corresponding with You</li>
</ul>
</td>
<td>
+223 -68
View File
@@ -1,32 +1,26 @@
export * as Command from "./command.js"
import { Command } from "@opencode-ai/schema/command"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Bus } from "./bus.js"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state.js"
import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
export const Info = Command.Info
export type Info = Command.Info
export { Event } from "@opencode-ai/schema/command"
export interface Invocation {
readonly sessionID: Session.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
export type Evaluation = {
readonly text: string
}
export interface Definition {
readonly name: string
readonly description?: string
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Draft = {
add: (definition: Definition) => void
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.NotFoundError", {
@@ -34,73 +28,234 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Command.
message: Schema.String,
}) {}
export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Command.ExecutionError", {
export class EvaluationError extends Schema.TaggedError<EvaluationError>()("Command.EvaluationError", {
command: Schema.String,
message: Schema.String,
}) {}
export type Draft = {
list: () => readonly Info[]
get: (name: string) => Info | undefined
update: (name: string, update: (command: Types.DeepMutable<Info>) => void) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
readonly execute: (input: {
readonly evaluate: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<void, NotFoundError | ExecutionError>
readonly arguments?: string
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Map<string, Definition>, Draft>({
name: "command",
initial: () => new Map(),
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
name: definition.name,
description: definition.description,
const layer = () =>
Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const processes = yield* AppProcess.Service
const location = yield* Location.Service
const shell = yield* ShellSelect.Service
const state = State.create<Data, Draft>({
name: "command",
initial: () => ({ commands: new Map() }),
draft: (draft) => ({
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
},
remove: (name) => {
draft.commands.delete(name)
},
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
return (yield* mcp.prompts()).map((prompt) =>
Info.make({
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
}),
)
})
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")((name) =>
Effect.sync(() => {
const definition = state.get().get(name)
return definition ? info(definition) : undefined
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")(function* (name) {
const command = staticCommand(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}),
),
list: Effect.fn("Command.list")(() => Effect.sync(() => Array.from(state.get().values(), info))),
execute: Effect.fn("Command.execute")(function* (input) {
const definition = state.get().get(input.name)
if (!definition)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
return yield* definition.execute(input.invocation).pipe(
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
list: Effect.fn("Command.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const names = new Set(commands.map((command) => command.name))
return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))]
}),
evaluate: Effect.fn("Command.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command)
return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
location,
processes,
shell,
})
const prompt = (yield* mcp.prompts()).find(
(prompt) => mcpCommandName(prompt.server, prompt.name) === input.name,
)
if (!prompt)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
const result = yield* mcp
.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.arguments ?? "")[index] ?? "",
]),
),
})
.pipe(
Effect.catchTag("MCP.NotFoundError", () =>
Effect.fail(
new EvaluationError({
command: input.name,
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
}),
),
),
)
if (!result)
return yield* new EvaluationError({
command: input.name,
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
})
return {
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
}
}),
})
}),
)
function evaluateTemplate(
command: string,
template: string,
input: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const expanded = evaluateArguments(template, input)
return { text: yield* evaluateShell(command, expanded, services) }
})
}
function evaluateArguments(template: string, input: string) {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim())
return `${withArguments}\n\n${input}`.trim()
return withArguments.trim()
}
const evaluateShell = Effect.fnUntraced(function* (
command: string,
text: string,
services: {
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{
combineOutput: true,
},
)
}),
})
}),
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError(
(error) =>
new EvaluationError({
command,
message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`,
}),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
layer: layer(),
deps: [MCP.node, Bus.node, AppProcess.node, Location.node, ShellSelect.node],
})
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object" && "message" in error && typeof error.message === "string")
return error.message
return "Command execution failed"
}
+12 -109
View File
@@ -1,18 +1,12 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Command } from "../../command.js"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
import { ShellSelect } from "../../shell/select.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown.js"
@@ -29,9 +23,6 @@ export const Plugin = define({
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const shell = yield* ShellSelect.Service
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
@@ -60,41 +51,17 @@ export const Plugin = define({
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.add({
name,
description: command.description,
execute: (input) =>
Effect.gen(function* () {
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
const commandAgent = yield* Effect.gen(function* () {
if (agent === undefined) return
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
return (yield* ctx.agent.get({ agentID: agent })).data
})
const model =
command.model === undefined
? commandAgent?.model
: {
id: Model.ID.make(command.model.model),
providerID: Provider.ID.make(command.model.providerID),
...(command.model.variant === undefined
? {}
: { variant: Model.VariantID.make(command.model.variant) }),
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: yield* evaluateTemplate(command.template, input.prompt.text, {
config,
location,
processes,
shell,
}),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined)
item.model = {
id: command.model.model,
providerID: command.model.providerID,
...(command.model.variant === undefined ? {} : { variant: command.model.variant }),
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
}
@@ -147,67 +114,3 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function evaluateTemplate(
template: string,
input: string,
services: {
readonly config: Config.Interface
readonly location: Location.Info
readonly processes: AppProcess.Interface
readonly shell: ShellSelect.Interface
},
) {
return Effect.gen(function* () {
const args = parseArguments(input)
const placeholders = template.match(placeholderRegex) ?? []
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
const text =
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
? `${withArguments}\n\n${input}`.trim()
: withArguments.trim()
const matches = Array.from(text.matchAll(shellRegex))
if (matches.length === 0) return text
const shell = yield* services.shell.preferred()
const outputs = yield* Effect.forEach(
matches,
(match) => {
const source = match[1] ?? ""
return services.processes
.run(
ChildProcess.make(shell, ShellSelect.args(shell, source), {
cwd: services.location.directory,
stdin: "ignore",
}),
{ combineOutput: true },
)
.pipe(
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
Effect.mapError((error) =>
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
),
)
},
{ concurrency: 2 },
)
const iterator = outputs[Symbol.iterator]()
return text.replace(shellRegex, () => iterator.next().value ?? "")
})
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g
const shellRegex = /!`([^`]+)`/g
+3 -4
View File
@@ -2,7 +2,7 @@ export * as MCP from "./index.js"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { ephemeral } from "@opencode-ai/schema/event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
@@ -19,7 +19,6 @@ import { State } from "../state.js"
import type { MCPClient } from "./client.js"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
export type ServerName = typeof ServerName.Type
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
@@ -454,7 +453,7 @@ export const layer = (options?: Options) =>
Effect.map((defs) => {
entry.prompts = defs.map((def) => toPrompt(name, def))
}),
Effect.andThen(bus.publish(PromptsChanged, { server: name })),
Effect.andThen(bus.publish(Command.Event.Updated, {})),
)
// Runs a connection callback under the server lock, dropping it if the connection is no longer
@@ -573,7 +572,7 @@ export const layer = (options?: Options) =>
yield* Scope.close(scope, Exit.void)
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
})
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
+7 -98
View File
@@ -1,10 +1,8 @@
export * as CommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Effect } from "effect"
import { Location } from "../location.js"
import { MCP } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
import PROMPT_REVIEW from "./command/review.txt"
@@ -12,104 +10,15 @@ export const Plugin = define({
id: "opencode.command",
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const loaded = { prompts: [] as MCP.Prompt[] }
yield* bus
.subscribe(MCP.PromptsChanged)
.pipe(
Stream.runForEach(() =>
mcp.prompts().pipe(
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("init", (command) => {
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
command.description = "guided AGENTS.md setup"
})
draft.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
ctx.session
.prompt({
...input.prompt,
sessionID: input.sessionID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
})
for (const prompt of loaded.prompts) {
draft.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
Effect.gen(function* () {
const result = yield* mcp.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(
(prompt.arguments ?? []).map((argument, index) => [
argument.name,
parseArguments(input.prompt.text)[index] ?? "",
]),
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
})
}
})
}),
})
function append(template: string, input: string) {
return [template, input.trim()].filter(Boolean).join("\n\n")
}
function parseArguments(input: string) {
return (input.match(argsRegex) ?? []).map((argument) => argument.replace(quoteTrimRegex, ""))
}
function promptMessageText(content: unknown) {
if (typeof content === "string") return content
if (!content || typeof content !== "object") return ""
if (!("type" in content) || content.type !== "text") return ""
if (!("text" in content) || typeof content.text !== "string") return ""
return content.text
}
function mcpCommandName(server: string, prompt: string) {
return `${sanitize(server)}:${sanitize(prompt)}`
}
function sanitize(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
}
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const quoteTrimRegex = /^["']|["']$/g
+1 -4
View File
@@ -402,10 +402,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) =>
runtime.session
.interrupt(input.sessionID, { continue: input.continue })
.pipe(Effect.map((interrupted) => ({ interrupted }))),
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
+1 -1
View File
@@ -236,7 +236,6 @@ const pre = [
MCPCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
@@ -275,6 +274,7 @@ const post = [
ConfigWebSearchPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
PlanPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+6 -67
View File
@@ -1,7 +1,6 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
@@ -13,9 +12,6 @@ import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const callbackFallbackPort = 1457
const callbackBindAttempts = 10
const callbackBindRetryDelay = 200
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
@@ -59,10 +55,11 @@ const browser = (app: App.Info) =>
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost")
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
@@ -89,9 +86,11 @@ const browser = (app: App.Info) =>
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
})
const port = yield* listen(server)
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://localhost:${port}/auth/callback`
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
@@ -105,66 +104,6 @@ const browser = (app: App.Info) =>
refresh: (value) => refresh(browserMethodID, value, app),
}) satisfies IntegrationOAuthMethodRegistration
function listen(server: Server) {
return bind(server, callbackPort).pipe(
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
cancel(callbackPort).pipe(
Effect.ignore,
Effect.andThen(Effect.sleep(callbackBindRetryDelay)),
Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)),
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe(
Effect.as(callbackFallbackPort),
Effect.catchIf(addressInUse, () =>
Effect.fail(
new Error(
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
),
),
),
),
),
),
),
)
}
function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect<void, Error> {
return bind(server, port).pipe(
Effect.catchIf(
(error) => addressInUse(error) && attempts > 1,
() => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))),
),
)
}
function bind(server: Server, port: number) {
return Effect.callback<void, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(port, "localhost", () => {
server.off("error", onError)
resume(Effect.void)
})
})
}
function cancel(port: number) {
return Effect.tryPromise({
try: (signal) =>
fetch(`http://localhost:${port}/cancel`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
}),
catch: (cause) => cause,
})
}
function addressInUse(error: Error) {
return "code" in error && error.code === "EADDRINUSE"
}
const headless = (app: App.Info) =>
({
integrationID: Integration.ID.make("openai"),
+2 -4
View File
@@ -146,10 +146,8 @@ bug.
For questions about creating, configuring, loading, publishing, or migrating
plugins, fetch the full [plugins guide](https://opencode.ai/v2/docs/build/plugins)
before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
before answering. This includes questions about the Effect plugin API, hooks,
transforms, tools, plugin context capabilities, and package entrypoints.
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
+44 -16
View File
@@ -246,14 +246,26 @@ export interface Interface {
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
command: string
text: string
arguments?: string
agent?: Agent.ID
model?: Model.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
resume?: boolean
}) => Effect.Effect<
SessionInbox.User,
| NotFoundError
| PromptConflictError
| AttachmentError
| SkillNotFoundError
| Command.NotFoundError
| Command.EvaluationError
>
readonly shell: (input: {
id?: Event.ID
sessionID: SessionSchema.ID
@@ -272,7 +284,7 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -643,19 +655,35 @@ const layer = Layer.effect(
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
const command = yield* commands.get(input.command)
if (!command)
return yield* new Command.NotFoundError({
command: input.command,
message: `Command not found: ${input.command}`,
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(Agent.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
return yield* result.prompt({
id: input.id,
sessionID: input.sessionID,
text: evaluated.text,
files: input.files,
agents: input.agents,
skills: input.skills,
delivery: input.delivery,
resume: input.resume,
})
}),
shell: Effect.fn("Session.shell")(function* (input) {
+6 -8
View File
@@ -24,10 +24,9 @@ export interface Interface {
/**
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
* settlement matters.
* Compose with `awaitIdle` when settlement matters.
*/
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -141,8 +140,8 @@ export const layer = Layer.effect(
active: coordinator.active,
interrupt: (sessionID, options) =>
Effect.gen(function* () {
const interrupted = yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return interrupted
yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
// promotes them, and a control item behind a queued prompt waits its turn.
@@ -152,10 +151,9 @@ export const layer = Layer.effect(
// rows inside uninterruptible publications, so a steer row is either still
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return interrupted
if (next === undefined) return
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
wake: coordinator.wake,
@@ -177,7 +175,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
+7 -8
View File
@@ -14,10 +14,9 @@ export interface Coordinator<Key, E, Reason = never> {
/**
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Returns whether an active execution was
* interrupted. Compose with `awaitIdle` for settlement.
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
*/
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
@@ -135,16 +134,16 @@ export const make = <Key, E, Reason = never>(options: {
start(key, false, scope)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<boolean> =>
Effect.sync(() => {
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined || execution.stopping) return false
if (execution === undefined || execution.stopping) return Effect.void
if (execution.owner === undefined) {
// Settlement window: the owner exited but the settled hook has not finished. The
// terminal outcome is already decided, so no reason attaches — but the interrupt
// still claims the recorded wakes so settle does not start a dead-intent successor.
execution.pendingWake = undefined
return false
return Effect.void
}
execution.stopping = true
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
@@ -154,7 +153,7 @@ export const make = <Key, E, Reason = never>(options: {
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
fork(Fiber.interrupt(execution.owner))
return true
return Effect.void
})
// One execution's `done` already spans coalesced continuations; re-check after it
+28 -77
View File
@@ -1,20 +1,7 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Cache, Effect, JsonSchema, Schema, SchemaIssue, SchemaRepresentation } from "effect"
const formatEffectIssues = SchemaIssue.makeFormatterStandardSchemaV1()
const jsonSchemas = Effect.runSync(
Cache.make<JsonSchema.JsonSchema, Schema.Codec<unknown> | undefined>({
capacity: 100,
lookup: (schema) =>
Effect.try({
try: () => jsonSchema(schema),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined)),
}),
)
import { Effect, JsonSchema, Schema } from "effect"
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
name: effectiveName(tool),
@@ -25,7 +12,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool, input)
const decoded = yield* decodeInput(tool.input, input)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
@@ -57,51 +44,13 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
}
})
const decodeInput = (tool: Tool.Info<any, any>, value: unknown) =>
Effect.gen(function* () {
const result = yield* validateInput(tool.input, value)
if (result.issues)
return yield* new Tool.Error({ message: formatInputIssues(effectiveName(tool), result.issues, value) })
return result.value
})
const validateInput = (
schema: Tool.ValueSchema<any>,
value: unknown,
): Effect.Effect<StandardSchemaV1.Result<unknown>> => {
if (isStandardSchema(schema)) return validateStandard(schema, value)
return Effect.gen(function* () {
const codec = Schema.isSchema(schema) ? schema : yield* Cache.get(jsonSchemas, schema)
if (codec === undefined) return { value }
return yield* Schema.decodeUnknownEffect(codec)(value, { errors: "all" }).pipe(
Effect.match({
onFailure: (error) => formatEffectIssues(error.issue),
onSuccess: (value) => ({ value }),
}),
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
)
})
}
const formatInputIssues = (tool: string, issues: ReadonlyArray<StandardSchemaV1.Issue>, value: unknown) => {
const details = issues.slice(0, 5).map((issue) => {
const path =
issue.path?.reduce<string>((path, segment) => {
const key = typeof segment === "object" ? segment.key : segment
if (typeof key === "number") return `${path}[${key}]`
return path === "" ? String(key) : `${path}.${String(key)}`
}, "") || "root"
return `- ${path}: ${issue.message}`
})
if (issues.length > 5) details.push(`- ...and ${issues.length - 5} more ${issues.length === 6 ? "issue" : "issues"}`)
return `Invalid arguments for tool "${tool}":\n${details.join("\n")}\n\nArguments provided:\n${JSON.stringify(value, null, 2)}\n\nUpdate the arguments and call the tool again.`
}
const jsonSchema = (schema: JsonSchema.JsonSchema) => {
const draft =
(typeof schema.$schema === "string" && schema.$schema.includes("draft-07")) || "definitions" in schema
? JsonSchema.fromSchemaDraft07(schema)
: JsonSchema.fromSchemaDraft2020_12(schema)
return Schema.make<Schema.Codec<unknown>>(SchemaRepresentation.fromJsonSchemaDocument(draft).ast)
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
@@ -113,15 +62,7 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
),
)
if (isStandardSchema(schema))
return validateStandard(schema, value).pipe(
Effect.flatMap((result) =>
result.issues
? new Tool.Error({
message: `Tool returned an invalid value for its output schema: ${result.issues.map((issue) => issue.message).join(", ")}`,
})
: Effect.succeed(result.value),
),
)
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Effect.mapError(
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
@@ -137,16 +78,26 @@ const isStandardSchema = (
const validateStandard = (
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
value: unknown,
): Effect.Effect<StandardSchemaV1.Result<unknown>> =>
prefix: string,
) =>
Effect.gen(function* () {
const result = yield* Effect.try({ try: () => schema["~standard"].validate(value), catch: (error) => error })
return result instanceof Promise ? yield* Effect.tryPromise({ try: () => result, catch: (error) => error }) : result
}).pipe(
Effect.match({
onFailure: (error) => ({ issues: [{ message: error instanceof Error ? error.message : String(error) }] }),
onSuccess: (result) => result,
}),
)
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* new Tool.Error({
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
})
return result.value
})
const standardFailure = (prefix: string, error: unknown) =>
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (schema === undefined || schema === null) return {}
+14 -54
View File
@@ -25,18 +25,9 @@ export class Info extends Schema.Class<Info>("Workspace.Info")({
export class NotFound extends Schema.TaggedError<NotFound>()("Workspace.NotFound", { workspaceID: ID }) {}
export class CreateConflict extends Schema.TaggedError<CreateConflict>()("Workspace.CreateConflict", {
workspaceID: ID,
provider: Schema.String,
existingProvider: Schema.String,
}) {}
export interface Interface {
/** Instantly commits a logical workspace ID. No provider work happens here. */
readonly create: (input: {
readonly id?: ID
readonly provider: string
}) => Effect.Effect<ID, CreateConflict | WorkspaceDriver.ProviderNotFound>
readonly create: (provider: string) => Effect.Effect<ID, WorkspaceDriver.ProviderNotFound>
/** Starts or joins the shared attempt that makes the backing resource real, then returns it. */
readonly provision: (
workspaceID: ID,
@@ -44,11 +35,9 @@ export interface Interface {
readonly connect: (
workspaceID: ID,
) => Effect.Effect<EnvironmentDriver, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
/** Makes the workspace absent; reports whether this call destroyed an existing workspace. */
readonly destroy: (workspaceID: ID) => Effect.Effect<
Workspace.DestroyResult,
WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound
>
readonly destroy: (
workspaceID: ID,
) => Effect.Effect<void, NotFound | WorkspaceDriver.Error | WorkspaceDriver.ProviderNotFound>
}
export interface Options {
@@ -90,16 +79,13 @@ const layer = (options: Options) =>
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const idleThreshold = Duration.toMillis(options.idleThreshold ?? Duration.minutes(20))
const find = (workspaceID: ID) =>
db
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
const row = yield* db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.get()
.pipe(Effect.orDie)
const load = Effect.fn("Workspace.load")(function* (workspaceID: ID) {
const row = yield* find(workspaceID)
if (!row) return yield* new NotFound({ workspaceID })
return row
})
@@ -221,39 +207,15 @@ const layer = (options: Options) =>
}).pipe(Effect.repeat(Schedule.spaced(options.pollInterval ?? Duration.minutes(1))), Effect.forkScoped)
return Service.of({
create: Effect.fn("Workspace.create")(function* (input) {
const workspaceID = input.id ?? ID.create()
const existing = yield* db
.select({ provider: WorkspaceTable.provider })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.get()
.pipe(Effect.orDie)
if (existing) {
if (existing.provider === input.provider) return workspaceID
return yield* new CreateConflict({
workspaceID,
provider: input.provider,
existingProvider: existing.provider,
})
}
yield* registry.get(input.provider)
create: Effect.fn("Workspace.create")(function* (provider) {
yield* registry.get(provider)
const workspaceID = ID.create()
const now = yield* Clock.currentTimeMillis
const inserted = yield* db
yield* db
.insert(WorkspaceTable)
.values({ id: workspaceID, provider: input.provider, binding: null, created_at: now, last_used_at: now })
.onConflictDoNothing()
.returning({ id: WorkspaceTable.id })
.get()
.values({ id: workspaceID, provider, binding: null, created_at: now, last_used_at: now })
.run()
.pipe(Effect.orDie)
if (inserted) return workspaceID
const row = yield* load(workspaceID).pipe(Effect.orDie)
if (row.provider !== input.provider)
return yield* new CreateConflict({
workspaceID,
provider: input.provider,
existingProvider: row.provider,
})
return workspaceID
}),
provision,
@@ -305,10 +267,9 @@ const layer = (options: Options) =>
attempts.delete(workspaceID)
Deferred.doneUnsafe(attempt, Exit.fail(new NotFound({ workspaceID })))
}
return yield* locks.withLock(workspaceID)(
yield* locks.withLock(workspaceID)(
Effect.gen(function* () {
const row = yield* find(workspaceID)
if (!row) return { destroyed: false }
const row = yield* load(workspaceID)
const connection = connections.get(workspaceID)
connections.delete(workspaceID)
if (connection) yield* Scope.close(connection.scope, Exit.void)
@@ -323,7 +284,6 @@ const layer = (options: Options) =>
),
)
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).run().pipe(Effect.orDie)
return { destroyed: true }
}),
)
}),
+60 -54
View File
@@ -1,71 +1,77 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Command } from "@opencode-ai/core/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(Command.node))
const it = testEffect(
AppNodeBuilder.build(Command.node, [
[MCP.node, emptyMcpLayer],
[Location.node, testLocationLayer],
]),
)
describe("Command", () => {
it.effect("registers and executes callback commands", () =>
it.effect("applies command transforms and preserves later overrides", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const calls: Command.Invocation[] = []
yield* command.transform((draft) => {
draft.add({
name: "goal",
description: "Manage the session goal",
execute: (input) => Effect.sync(() => calls.push(input)),
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "First"
command.description = "Review code"
})
editor.update("review", (command) => {
command.template = "Second"
command.model = {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
}
})
})
expect(yield* command.get("goal")).toEqual(
Command.Info.make({ name: "goal", description: "Manage the session goal" }),
)
const invocation = {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
delivery: "steer" as const,
}
yield* command.execute({ name: "goal", invocation })
expect(calls).toEqual([invocation])
}),
)
it.effect("replaces commands with later definitions", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((draft) => {
draft.add({ name: "goal", description: "First", execute: () => Effect.void })
draft.add({ name: "goal", description: "Second", execute: () => Effect.void })
})
expect(yield* command.list()).toEqual([Command.Info.make({ name: "goal", description: "Second" })])
}),
)
it.effect("returns callback error messages without stack traces", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((draft) => {
draft.add({
name: "fail",
execute: () => Effect.fail(new Error("command failed")),
})
})
const error = yield* command
.execute({
name: "fail",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "" },
delivery: "steer",
expect(yield* command.get("review")).toEqual(
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
)
expect(yield* command.list()).toEqual([
Command.Info.make({
name: "review",
template: "Second",
description: "Review code",
model: {
id: Model.ID.make("claude"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("high"),
},
}),
])
}),
)
it.effect("evaluates command template shell blocks", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.update("review", (command) => {
command.template = "Output: !`echo command-output`"
})
.pipe(Effect.flip)
expect(error).toMatchObject({ _tag: "Command.ExecutionError", message: "command failed" })
})
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
}),
)
})
+47 -126
View File
@@ -1,13 +1,11 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { advance, drain } from "../lib/clock"
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Command } from "@opencode-ai/core/command"
import { Agent } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -17,11 +15,11 @@ import { Bus } from "@opencode-ai/core/bus"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
@@ -30,25 +28,12 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const shellLayer = Layer.succeed(
ShellSelect.Service,
ShellSelect.Service.of({
preferred: () => Effect.succeed("sh"),
transform: () => Effect.die("unused shell.transform"),
reload: () => Effect.die("unused shell.reload"),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
],
),
AppNodeBuilder.build(LayerNode.group([Command.node, Bus.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
]),
)
const decode = Schema.decodeUnknownSync(Info)
@@ -80,7 +65,6 @@ Review files`,
const bus = yield* Bus.Service
const update = yield* bus.publish(Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
const prompts: { text: string; files?: readonly { readonly uri: string }[]; delivery?: string }[] = []
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
@@ -89,20 +73,6 @@ Review files`,
reload: command.reload,
},
event: { subscribe: () => Stream.fromPubSub(updates) },
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
}),
).pipe(
Effect.provide(
@@ -119,46 +89,28 @@ Review files`,
expect(yield* command.list()).toEqual([
Command.Info.make({
name: "review",
template: "Review files",
description: "File review",
agent: Agent.ID.make("reviewer"),
model: {
providerID: Provider.ID.make("anthropic"),
id: Model.ID.make("claude"),
variant: Model.VariantID.make("high"),
},
subtask: true,
}),
Command.Info.make({ name: "empty" }),
Command.Info.make({ name: "nested/docs" }),
])
yield* command.execute({
name: "nested/docs",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
})
expect(prompts).toEqual([
{
text: "Write docs\n\ndetails",
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
Command.Info.make({ name: "empty", template: "" }),
Command.Info.make({ name: "nested/docs", template: "Write docs" }),
])
yield* Effect.promise(() =>
fs.writeFile(path.join(tmp.path, "commands", "review.md"), markdown("Review again", "Review again")),
)
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.description === "Review again") break
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.description).toBe("Review again")
yield* command.execute({
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "latest" },
delivery: "steer",
},
})
expect(prompts.at(-1)?.text).toBe("Review again\n\nlatest")
expect((yield* command.get("review"))?.template).toBe("Review again")
}),
),
),
@@ -241,13 +193,11 @@ Review files`,
yield* advance(() => reloads >= 1)
expect(reloads).toBe(1)
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
)
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review twice"))
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 2)
expect(reloads).toBe(2)
expect((yield* command.get("review"))?.description).toBe("Review twice")
expect((yield* command.get("review"))?.template).toBe("Review twice")
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
@@ -282,12 +232,10 @@ Review files`,
expect(reloads).toBe(0)
// The feed stays live after unrelated updates.
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
)
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review related"))
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
yield* advance(() => reloads >= 1)
expect((yield* command.get("review"))?.description).toBe("Review related")
expect((yield* command.get("review"))?.template).toBe("Review related")
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
),
),
@@ -324,47 +272,28 @@ describeNative("ConfigCommandPlugin native watcher", () => {
yield* watchReady(config, global)
const created = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
markdown("Review native", "Review native"),
)
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native")
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native")
expect((yield* command.get("review"))?.template).toBe("Review native")
const updated = yield* nextCommandUpdate(bus)
yield* fs.writeFileString(
path.join(global, "commands", "review.md"),
markdown("Review native again", "Review native again"),
)
yield* fs.writeFileString(path.join(global, "commands", "review.md"), "Review native again")
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
expect((yield* command.get("review"))?.description).toBe("Review native again")
expect((yield* command.get("review"))?.template).toBe("Review native again")
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([
Command.node,
Config.node,
Bus.node,
FSUtil.node,
AppProcess.node,
Global.node,
Location.node,
ShellSelect.node,
]),
AppNodeBuilder.build(LayerNode.group([Command.node, Config.node, Bus.node, FSUtil.node]), [
[
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
),
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
]),
),
)
}),
@@ -408,10 +337,6 @@ function directoryEntry(directory: string) {
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
}
function markdown(description: string, template: string) {
return `---\ndescription: ${description}\n---\n${template}`
}
function sourceCases() {
return [
{
@@ -420,37 +345,33 @@ function sourceCases() {
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
await fs.writeFile(file, markdown("Review created", "Review created"))
await fs.writeFile(file, "Review created")
return [{ type: "create" as const, path: file }]
}),
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect((yield* command.get("review"))?.description).toBe("Review created")
expect((yield* command.get("review"))?.template).toBe("Review created")
}),
},
{
name: "updated",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review first")),
mutate: (directory: string) =>
Effect.promise(async () => {
const file = path.join(directory, "review.md")
await fs.writeFile(file, markdown("Review updated", "Review updated"))
await fs.writeFile(file, "Review updated")
return [{ type: "update" as const, path: file }]
}),
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect((yield* command.get("review"))?.description).toBe("Review updated")
expect((yield* command.get("review"))?.template).toBe("Review updated")
}),
},
{
name: "renamed",
prepare: (directory: string) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "review.md"), markdown("Review renamed", "Review renamed")),
),
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review renamed")),
mutate: (directory: string) =>
Effect.promise(async () => {
const previous = path.join(directory, "review.md")
@@ -464,7 +385,7 @@ function sourceCases() {
verify: (command: Command.Interface) =>
Effect.gen(function* () {
expect(yield* command.get("review")).toBeUndefined()
expect((yield* command.get("release"))?.description).toBe("Review renamed")
expect((yield* command.get("release"))?.template).toBe("Review renamed")
}),
},
{
-11
View File
@@ -52,17 +52,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("allows the built-in Plan agent to be disabled", () =>
withLocation(
{ agents: { plan: { disabled: true } } },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("plan"))).toBeUndefined()
}),
),
)
it.live("loads configured Promise plugins with options", () =>
withLocation(
{
+2 -8
View File
@@ -14,22 +14,16 @@ import { Bus } from "@opencode-ai/core/bus"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
)
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
File diff suppressed because one or more lines are too long
+2 -43
View File
@@ -1,18 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Command } from "@opencode-ai/core/command"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime } from "effect"
import { emptyMcpLayer } from "../fixture/mcp"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
@@ -23,18 +15,12 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory }, { projectDirectory: project })),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
[MCP.node, emptyMcpLayer],
[Location.node, locationLayer],
]),
)
const it = testEffect(AppNodeBuilder.build(Command.node, [[Location.node, locationLayer]]))
describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () =>
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: { text: string; files?: readonly { readonly uri: string }[] }[] = []
yield* CommandPlugin.Plugin.effect(
host({
command: {
@@ -42,20 +28,6 @@ describe("CommandPlugin.Plugin", () => {
transform: command.transform,
reload: command.reload,
},
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
})
}),
},
}),
).pipe(
Effect.provideService(
@@ -68,24 +40,11 @@ describe("CommandPlugin.Plugin", () => {
name: "init",
description: "guided AGENTS.md setup",
})
expect((yield* command.get("init"))?.template).toContain("`/repo`")
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
})
yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
})
expect(prompts).toEqual([
{
text: expect.stringContaining("extra context"),
files: [{ uri: "file:///tmp/context.md" }],
},
])
}),
)
})
+3 -5
View File
@@ -121,7 +121,7 @@ describe("fromPromise", () => {
}),
)
it.effect("preserves interrupt results and rejected Promise behavior", () =>
it.effect("preserves no-content and rejected Promise behavior", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const host = testHost({
@@ -131,7 +131,7 @@ describe("fromPromise", () => {
return Effect.fail(new Error("interrupt failed"))
}
expect(input.continue).toBe(true)
return Effect.succeed({ interrupted: false })
return Effect.void
},
switchAgent: (input) => Effect.sync(() => seen.push(input)),
switchModel: (input) => Effect.sync(() => seen.push(input)),
@@ -144,9 +144,7 @@ describe("fromPromise", () => {
define({
id: "promise-session-interrupt",
setup: async (ctx) => {
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toEqual({
interrupted: false,
})
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
expect(await ctx.session.switchAgent({ sessionID: "ses_success", agent: "build" })).toBeUndefined()
expect(
+1 -14
View File
@@ -128,25 +128,12 @@ describe("SessionExecution lifecycle", () => {
yield* Deferred.await(draining)
expect((yield* claims(database))[sessionID]).toBe(true)
expect(yield* execution.interrupt(sessionID)).toBeTrue()
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
expect((yield* claims(database))[sessionID]).toBe(false)
}),
)
it.effect("reports an idle interrupt as a no-op", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_idle_cancel")
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.never)
const execution = Context.get(context, SessionExecution.Service)
expect(yield* execution.interrupt(sessionID)).toBeFalse()
expect(yield* execution.active).not.toContain(sessionID)
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
+1 -2
View File
@@ -48,7 +48,6 @@ const execution = Layer.succeed(
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
return activeSessions.delete(sessionID)
}),
wake: (sessionID) =>
Effect.sync(() => {
@@ -194,7 +193,7 @@ describe("Session.prompt", () => {
interruptCalls.length = 0
wakeCalls.length = 0
expect(yield* session.interrupt(sessionID)).toBeFalse()
yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
@@ -236,7 +236,7 @@ describe("SessionRunCoordinator", () => {
drain: () => Effect.void,
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
yield* coordinator.interrupt("session", "user")
yield* coordinator.run("session")
expect(reasons).toEqual([undefined])
}),
@@ -260,7 +260,7 @@ describe("SessionRunCoordinator", () => {
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(settling)
expect(yield* coordinator.interrupt("session", "user")).toBeFalse()
yield* coordinator.interrupt("session", "user")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
yield* coordinator.run("session")
@@ -315,7 +315,7 @@ describe("SessionRunCoordinator", () => {
const idle = yield* coordinator.awaitIdle("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session")
expect(yield* coordinator.interrupt("session", "user")).toBeTrue()
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(interrupted)
const exits = yield* Fiber.awaitAll([first, second, idle])
@@ -511,11 +511,7 @@ describe("Tool", () => {
}),
).toMatchObject({
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "transformed":\n- value: Expected boolean\n\nArguments provided:\n{\n "value": "yes"\n}\n\nUpdate the arguments and call the tool again.',
},
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(executed).toEqual(["yes"])
+1 -5
View File
@@ -100,11 +100,7 @@ describe("QuestionTool", () => {
}),
).toMatchObject({
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.',
},
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
+17 -160
View File
@@ -144,12 +144,7 @@ test("portable schema failures become tool failures", async () => {
"~standard": {
version: 1,
vendor: "test",
validate: (_value: unknown) => ({
issues: [
{ path: ["value"], message: "expected a string" },
{ path: [{ key: "nested" }, { key: "count" }], message: "expected a positive integer" },
],
}),
validate: (_value: unknown) => ({ issues: [{ message: "expected a string" }] }),
jsonSchema: {
input: () => ({ type: "string" }),
output: () => ({ type: "string" }),
@@ -157,76 +152,19 @@ test("portable schema failures become tool failures", async () => {
},
}
const error = await Effect.runPromise(
Effect.flip(
execute(
{
name: "invalid",
description: "Invalid",
input,
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
),
const error = await Effect.runPromiseExit(
execute(
{
name: "invalid",
description: "Invalid",
input,
execute: () => Effect.succeed({ content: "unused" }),
},
1,
{} as Tool.Context,
),
)
expect(error).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "invalid":\n- value: expected a string\n- nested.count: expected a positive integer\n\nArguments provided:\n1\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("Effect schema failures use normalized input issues", async () => {
const tool: Info = {
name: "effect",
description: "Effect tool",
input: Schema.Struct({
value: Schema.String,
nested: Schema.Struct({ count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)) }),
}),
execute: () => Effect.succeed({ content: "unused" }),
}
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("input error prompts limit normalized issues", async () => {
const input = {
"~standard": {
version: 1,
vendor: "test",
validate: (_value: unknown) => ({
issues: Array.from({ length: 6 }, (_, index) => ({ message: `issue ${index + 1}` })),
}),
jsonSchema: {
input: () => ({}),
output: () => ({}),
},
},
}
const tool: Info = {
name: "limited",
description: "Limited issues",
input,
execute: () => Effect.succeed({ content: "unused" }),
}
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(error.toString()).toContain("Invalid tool input: expected a string")
})
test("canonical results carry metadata with typed output", async () => {
@@ -247,21 +185,8 @@ test("canonical results carry metadata with typed output", async () => {
})
})
test("raw JSON schemas validate and decode tool input", async () => {
const input = {
type: "object",
properties: {
value: { type: "string" },
nested: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
required: ["value"],
additionalProperties: false,
}
test("raw JSON schemas are render-only and omitted output means model-only", async () => {
const input = { type: "object", properties: { value: { type: "string" } } }
const tool: Info = {
name: "raw",
description: "Raw tool",
@@ -272,79 +197,11 @@ test("raw JSON schemas validate and decode tool input", async () => {
expect(definition(tool)).toEqual({
name: "raw",
description: "Raw tool",
inputSchema: input,
inputSchema: { type: "object", properties: { value: { type: "string" } } },
})
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
expect(await Effect.runPromise(execute(tool, { value: 1 }, {} as Tool.Context))).toEqual({
output: undefined,
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
expect(
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("raw JSON schemas resolve draft-07 definitions", async () => {
const tool: Info = {
name: "draft-07",
description: "Draft-07 tool",
input: {
type: "object",
properties: { value: { $ref: "#/definitions/value" } },
required: ["value"],
definitions: { value: { type: "string" } },
},
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
content: [{ type: "text", text: '{"value":"ok"}' }],
})
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
new Tool.Error({
message:
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
}),
)
})
test("raw JSON schemas pass input through when they cannot be imported", async () => {
const tool: Info = {
name: "invalid-schema",
description: "Invalid schema tool",
input: {
type: "object",
properties: { value: { $ref: "#/$defs/missing" } },
},
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
}
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
content: [{ type: "text", text: '{"value":1}' }],
})
})
+1 -2
View File
@@ -118,8 +118,7 @@ describe("search tools", () => {
status: "error",
error: {
type: "tool.execution",
message:
'Invalid arguments for tool "grep":\n- pattern: Pattern must not be empty\n\nArguments provided:\n{\n "pattern": ""\n}\n\nUpdate the arguments and call the tool again.',
message: 'Invalid tool input: Pattern must not be empty\n at ["pattern"]',
},
})
}),
+1 -1
View File
@@ -115,7 +115,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
})
}),
+1 -1
View File
@@ -88,7 +88,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.succeed(false),
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
})
}),
+12 -97
View File
@@ -41,7 +41,7 @@ const driver = WorkspaceDriver.make({
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Workspace.configured({ idleThreshold: "5 minutes", pollInterval: "1 minute" })]),
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver, other: driver })]],
[[WorkspaceDriver.node, WorkspaceDriver.registryNode({ fake: driver })]],
),
)
@@ -77,7 +77,7 @@ it.effect("rejects unregistered workspace providers", () =>
it.effect("creates and persists an ID without provisioning", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
expect(workspaceID.startsWith("wrk_")).toBe(true)
expect(calls).toEqual([])
@@ -89,76 +89,12 @@ it.effect("creates and persists an ID without provisioning", () =>
}),
)
it.effect("creates a workspace with a caller-supplied ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get(),
).pipe(Effect.orDie),
).toMatchObject({ id, provider: "fake", binding: null })
}),
)
it.effect("reuses a caller-supplied ID with the same provider", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(yield* workspace.create({ id, provider: "fake" })).toBe(id)
expect(
yield* Database.Service.use(({ db }) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).all(),
).pipe(Effect.orDie),
).toHaveLength(1)
expect(calls).toEqual([])
}),
)
it.effect("rejects a caller-supplied ID already assigned to another provider", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
yield* workspace.create({ id, provider: "fake" })
expect(yield* workspace.create({ id, provider: "other" }).pipe(Effect.flip)).toEqual(
new Workspace.CreateConflict({ workspaceID: id, provider: "other", existingProvider: "fake" }),
)
}),
)
it.effect("resolves an existing caller-supplied ID before provider lookup", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const id = Workspace.ID.create()
yield* Database.Service.use(({ db }) =>
db
.insert(WorkspaceTable)
.values({ id, provider: "missing", binding: null, created_at: 0, last_used_at: 0 })
.run(),
).pipe(Effect.orDie)
expect(yield* workspace.create({ id, provider: "missing" })).toBe(id)
expect(yield* workspace.create({ id, provider: "another-missing" }).pipe(Effect.flip)).toEqual(
new Workspace.CreateConflict({
workspaceID: id,
provider: "another-missing",
existingProvider: "missing",
}),
)
}),
)
it.effect("destroys an unprovisioned workspace through the driver with a null binding", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
yield* workspace.destroy(workspaceID)
expect(calls).toEqual([{ operation: "destroy", binding: null }])
expect(
yield* Database.Service.use(({ db }) =>
@@ -168,31 +104,10 @@ it.effect("destroys an unprovisioned workspace through the driver with a null bi
}),
)
it.effect("succeeds without calling the driver when the workspace does not exist", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = Workspace.ID.create()
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
expect(calls).toEqual([])
}),
)
it.effect("reports whether destroy removed an existing workspace", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: true })
expect(yield* workspace.destroy(workspaceID)).toEqual({ destroyed: false })
expect(calls).toEqual([{ operation: "destroy", binding: null }])
}),
)
it.effect("starts eager provisioning in the background and lets callers join it", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const eager = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -211,7 +126,7 @@ it.effect("starts eager provisioning in the background and lets callers join it"
it.effect("starts lazy provisioning on the first spawn", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
@@ -230,7 +145,7 @@ it.effect("starts lazy provisioning on the first spawn", () =>
it.effect("shares provisioning between concurrent first spawns", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const environment = yield* workspace.connect(workspaceID)
const gate = yield* gateCreate()
@@ -254,7 +169,7 @@ it.effect("shares provisioning between concurrent first spawns", () =>
it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const owner = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -272,7 +187,7 @@ it.effect("keeps shared provisioning alive when a waiter is interrupted", () =>
it.effect("interrupts in-flight provisioning on destroy and fails waiters with NotFound", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const gate = yield* gateCreate()
const waiter = yield* workspace.provision(workspaceID).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -293,7 +208,7 @@ it.effect("interrupts in-flight provisioning on destroy and fails waiters with N
it.effect("shares a failed attempt and retries the same workspace ID", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let fail = true
@@ -327,7 +242,7 @@ it.effect("shares a failed attempt and retries the same workspace ID", () =>
it.effect("persists the workspace lifecycle and reconnects after idle suspension", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const workspaceID = yield* workspace.create({ provider: "fake" })
const workspaceID = yield* workspace.create("fake")
const created = yield* workspace.provision(workspaceID)
expect(created.id).toBe(workspaceID)
@@ -362,7 +277,7 @@ it.effect("persists the workspace lifecycle and reconnects after idle suspension
it.effect("surfaces wake failures through the spawn error channel", () =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const created = yield* workspace.provision(yield* workspace.create({ provider: "fake" }))
const created = yield* workspace.provision(yield* workspace.create("fake"))
const environment = yield* workspace.connect(created.id)
yield* Effect.scoped(environment.spawner.spawn(ChildProcess.make("connect"))).pipe(Effect.exit)
-3
View File
@@ -5,9 +5,6 @@
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2",
+26 -26
View File
@@ -5,7 +5,6 @@ import { jwtVerify, createRemoteJWKSet } from "jose"
import { createAppAuth } from "@octokit/auth-app"
import { Octokit } from "@octokit/rest"
import { Resource } from "sst"
import { parseRepositoryClaim } from "./github"
type Env = {
SYNC_SERVER: DurableObjectNamespace<SyncServer>
@@ -270,41 +269,42 @@ export default new Hono<{ Bindings: Env }>()
// verify token
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
let repository: ReturnType<typeof parseRepositoryClaim>
let owner, repo
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: GITHUB_ISSUER,
audience: EXPECTED_AUDIENCE,
})
repository = parseRepositoryClaim(payload)
const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main'
const parts = sub.split(":")[1].split("/")
owner = parts[0]
repo = parts[1]
} catch (err) {
console.error("Token verification failed:", err)
return c.json({ error: "Invalid or expired token" }, { status: 403 })
}
try {
const auth = createAppAuth({
appId: Resource.GITHUB_APP_ID.value,
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
})
const appAuth = await auth({ type: "app" })
const octokit = new Octokit({ auth: appAuth.token })
const { data: installation } = await octokit.apps.getRepoInstallation({
owner: repository.owner,
repo: repository.repo,
})
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
} catch (error) {
console.error("GitHub App token exchange failed:", error)
return c.json(
{ error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` },
{ status: 502 },
)
}
// Create app JWT token
const auth = createAppAuth({
appId: Resource.GITHUB_APP_ID.value,
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
})
const appAuth = await auth({ type: "app" })
// Lookup installation
const octokit = new Octokit({ auth: appAuth.token })
const { data: installation } = await octokit.apps.getRepoInstallation({
owner,
repo,
})
// Get installation token
const installationAuth = await auth({
type: "installation",
installationId: installation.id,
})
return c.json({ token: installationAuth.token })
})
/**
* Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally)

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