Compare commits

..

14 Commits

Author SHA1 Message Date
Aiden Cline 2a3340936d Merge branch 'llm-error-types' into llm-remove-provider-error 2026-07-13 15:15:54 -05:00
Aiden Cline f981861b67 Merge remote-tracking branch 'origin/v2' into llm-error-types 2026-07-13 15:15:43 -05:00
James Long 748b0d8836 refactor(tui): remove subtle syntax styles (#36746) 2026-07-13 16:14:56 -04:00
Kit Langton ed1636e3bd fix(tui): clamp dialog selection after options shrink (#36712) 2026-07-13 15:59:04 -04:00
Dax Raad 90a87b2a0c refactor(tui): remove legacy sync context 2026-07-13 15:46:25 -04:00
Kit Langton af8480b2e2 fix(tui): pin queued compaction below output (#36738) 2026-07-13 15:45:27 -04:00
Kit Langton 2096adae12 refactor(tui): remove unused subagent formatters (#36732) 2026-07-13 15:20:03 -04:00
Kit Langton 41349ff20a fix(tui): run dialog actions without selection (#36711) 2026-07-13 15:19:40 -04:00
Kit Langton a2f5a0df5d fix(core): compact after tool settlement (#36718) 2026-07-13 15:17:45 -04:00
Kit Langton 3bc924252b fix(www): generate collections before typecheck (#36730) 2026-07-13 15:07:45 -04:00
Kit Langton 8c1808ab74 fix(tui): standardize OpenCode product casing (#36717) 2026-07-13 15:01:48 -04:00
Dax Raad 685f887771 docs: clarify embedded sdk roadmap 2026-07-13 14:54:40 -04:00
Aiden Cline 5d87c7ad1f refactor(llm): remove the provider-error stream event
Delete the provider-error LLMEvent so streams carry output only and
every provider-reported failure exits through the typed error channel.
Anthropic SSE error events, OpenAI Responses response.failed/error
events, and Bedrock exception frames now fail the stream with an
LLMError from the shared classifier (carrying the provider code, e.g.
rate_limit_exceeded -> LLM.RateLimit).

Core follows: the publisher drops its provider-error case, the runner
drops held-back overflow events (overflow recovery keys off a thrown
LLM.ContextOverflow), compaction and title stop scanning events for
failures, and isContextOverflowFailure is deleted with its last
consumer. V1 packages/opencode gets minimal compile fixes only (dead
switch case, one test stream).
2026-07-13 12:22:30 -05:00
Aiden Cline fce506b3f9 refactor(llm): replace LLMError reasons with flat tagged union
Replace the LLMError { module, method, reason } wrapper with a flat
tagged union (LLM.BadRequest, LLM.Authentication, LLM.PermissionDenied,
LLM.NotFound, LLM.RateLimit, LLM.QuotaExceeded, LLM.ContentPolicy,
LLM.ContextOverflow, LLM.ServerError, LLM.APIError, LLM.ConnectionError,
LLM.TimeoutError, LLM.MalformedResponse, LLM.NoRoute) plus an isLLMError
guard. Add one shared classifyApiFailure classifier used by the HTTP
executor and the AI SDK adapter so both surfaces classify identically,
preserving status, headers, body, and retry-after.

Core policy moves onto tags: retry RateLimit | ServerError |
ConnectionError | TimeoutError; toSessionError adds
provider.context-overflow, provider.timeout, and provider.not-found.

The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
2026-07-13 12:21:21 -05:00
69 changed files with 1129 additions and 1174 deletions
-2
View File
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
this.themes.splice(index, 1)
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
public close(): void {
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
void resolveRunTheme(this.renderer).then((theme) => {
if (this.isGone) {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
return
}
+1 -5
View File
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([])
}
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
if (commit.kind === "reasoning") {
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
}
export function entrySyntax(theme: RunTheme): SyntaxStyle {
return syntax(theme.block.syntax)
}
+3 -3
View File
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
}
active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(theme)
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
? new CodeRenderable(surface.renderContext, {
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
wrapMode: "word",
drawUnstyledText: false,
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
})
: new MarkdownRenderable(surface.renderContext, {
content: "",
syntaxStyle: entrySyntax(commit, this.theme),
syntaxStyle: entrySyntax(this.theme),
width: "100%",
streaming: true,
internalBlockMode: "top-level",
+1 -1
View File
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
const syntax = createMemo(() => entrySyntax(theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
+1 -44
View File
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
text: ColorInput
muted: ColorInput
syntax?: SyntaxStyle
subtleSyntax?: SyntaxStyle
diffAdded: ColorInput
diffRemoved: ColorInput
diffAddedBg: ColorInput
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
)
}
function blend(color: RGBA, bg: RGBA): RGBA {
if (color.a >= 1) {
return color
}
return RGBA.fromValues(
bg.r + (color.r - bg.r) * color.a,
bg.g + (color.g - bg.g) * color.a,
bg.b + (color.b - bg.b) * color.a,
1,
)
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
if (!style) {
return undefined
}
return SyntaxStyle.fromStyles(
Object.fromEntries(
[...style.getAllStyles()].map(([name, value]) => [
name,
{
...value,
fg: value.fg ? blend(value.fg, bg) : value.fg,
bg: value.bg ? blend(value.bg, bg) : value.bg,
},
]),
),
)
}
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
return Array.from({ length: size }, (_, index) => {
const value = colors.palette[index]
@@ -502,10 +469,7 @@ function map(
scrollbackTheme: TuiThemeCurrent,
splash: RunSplashTheme,
syntax?: SyntaxStyle,
subtleSyntax?: SyntaxStyle,
): RunTheme {
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
subtleSyntax?.destroy()
const footerBackground = alpha(footerTheme.background, 1)
const footerMode = mode(footerBackground)
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
@@ -566,7 +530,6 @@ function map(
text: scrollbackTheme.text,
muted: scrollbackTheme.textMuted,
syntax,
subtleSyntax: opaqueSubtleSyntax,
diffAdded: scrollbackTheme.diffAdded,
diffRemoved: scrollbackTheme.diffRemoved,
diffAddedBg: transparent,
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
_hasSelectedListItemText: true,
}
const syntax = shared.generateSyntax(syntaxTheme)
return map(
footerTheme,
scrollbackTheme,
splashTheme(scrollbackTheme, indexed),
syntax,
shared.generateSubtleSyntax(syntaxTheme),
)
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
} catch {
return RUN_THEME_FALLBACK
}
+88 -16
View File
@@ -15,20 +15,43 @@ import type {
SharedV3ProviderOptions,
} from "@ai-sdk/provider"
import {
APIError,
Authentication,
BadRequest,
ConnectionError,
FinishReason,
InvalidProviderOutputReason,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
LLMEvent,
LLMError,
MalformedResponse,
Model,
NotFound,
ProviderID,
ProviderMetadata,
ToolResultValue,
UnknownProviderReason,
classifyApiFailure,
isLLMError,
type LLMError,
type ContentPart,
type LLMRequest,
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/llm"
import {
APICallError,
EmptyResponseBodyError,
InvalidArgumentError,
InvalidPromptError,
InvalidResponseDataError,
JSONParseError,
LoadAPIKeyError,
LoadSettingError,
NoContentGeneratedError,
NoSuchModelError,
TypeValidationError,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { ModelV2 } from "./model"
@@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: (error) => llmError("doStream", error),
catch: (error) => llmError(error),
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: (error) => llmError("readStream", error),
onError: (error) => llmError(error),
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@@ -608,7 +631,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError("stream", event.error))
return Effect.fail(llmError(event.error))
}
}
@@ -666,16 +689,65 @@ function messageValue(input: unknown) {
}
}
function llmError(method: string, error: unknown) {
const reason =
error instanceof LLMError
? new InvalidProviderOutputReason({ message: error.message })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
return new LLMError({
module: "AISDK",
method,
reason,
})
const BODY_LIMIT = 16_384
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
if (!headers) return undefined
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
const value = headers["retry-after"]
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
return undefined
}
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
// AI SDK route reports failures identically to native protocol routes. An
// `APICallError` without a status code is the AI SDK's representation of a
// network-level failure (connect refused, reset, DNS), not an API rejection.
function llmError(error: unknown): LLMError {
if (isLLMError(error)) return error
if (APICallError.isInstance(error)) {
if (error.statusCode === undefined) {
return new ConnectionError({ message: error.message, url: error.url, cause: error })
}
return classifyApiFailure({
message: error.message,
status: error.statusCode,
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
}),
})
}
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
return new Authentication({ message: error.message })
}
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
if (
InvalidPromptError.isInstance(error) ||
InvalidArgumentError.isInstance(error) ||
UnsupportedFunctionalityError.isInstance(error)
) {
return new BadRequest({ message: error.message })
}
if (
InvalidResponseDataError.isInstance(error) ||
JSONParseError.isInstance(error) ||
TypeValidationError.isInstance(error) ||
EmptyResponseBodyError.isInstance(error) ||
NoContentGeneratedError.isInstance(error)
) {
return new MalformedResponse({ message: error.message })
}
return new APIError({ message: error instanceof Error ? error.message : String(error) })
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
+1 -1
View File
@@ -1,6 +1,6 @@
export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "./effect/app-node"
+2 -7
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
import { Config } from "../config"
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
}
return Effect.void
}),
Effect.catchTag("LLM.Error", (error) =>
Effect.catchIf(isLLMError, (error) =>
Effect.sync(() => {
failure = toSessionError(error)
}),
+11 -26
View File
@@ -1,15 +1,6 @@
export * as SessionRunnerLLM from "./llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@@ -227,17 +218,10 @@ const layer = Layer.effect(
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event
return
}
}
if (publisher.hasProviderError()) return
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
@@ -317,22 +301,21 @@ const layer = Layer.effect(
// away non-interrupt failures, so both interrupt checks stay Cause-based.
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
// A context overflow before any assistant output is recoverable: compact and
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
llmFailure?._tag === "LLM.ContextOverflow" &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
"completed"
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
// already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
// A thrown LLM failure records the assistant failure unless a provider failure
// was already recorded from the stream. Terminal publication waits for owned tools.
if (llmFailure && !publisher.hasProviderError()) {
const error = toSessionError(llmFailure)
if (
@@ -349,7 +332,8 @@ const layer = Layer.effect(
}
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here.
// The provider-failed flag is only set while consuming the stream (content-filter
// step finish), so it is final here.
const providerFailed = publisher.hasProviderError()
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
@@ -543,7 +527,8 @@ const layer = Layer.effect(
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
@@ -438,10 +438,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
case "finish":
return
case "provider-error":
providerFailed = true
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
return
}
})
+19 -15
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/llm"
import type { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
@@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
}> {}
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
switch (error._tag) {
case "LLM.RateLimit":
case "LLM.ServerError":
case "LLM.ConnectionError":
case "LLM.TimeoutError":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
case "LLM.Authentication":
case "LLM.PermissionDenied":
case "LLM.NotFound":
case "LLM.QuotaExceeded":
case "LLM.ContentPolicy":
case "LLM.ContextOverflow":
case "LLM.MalformedResponse":
case "LLM.BadRequest":
case "LLM.NoRoute":
case "LLM.APIError":
return false
default: {
const exhaustive: never = error.reason
const exhaustive: never = error
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
return failure.cause.retryAfterMs
return undefined
}
+3 -5
View File
@@ -1,6 +1,6 @@
export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent"
import { Database } from "../database/database"
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!resolved) return
const chunks: string[] = []
let failed = false
const streamed = yield* dependencies.llm
.stream(
LLM.request({
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
)
if (!streamed || failed) return
if (!streamed) return
const title = chunks
.join("")
.split("\n")
+32 -24
View File
@@ -1,4 +1,4 @@
import { LLMError, ToolFailure } from "@opencode-ai/llm"
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission"
@@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
if (isLLMError(cause)) {
switch (cause._tag) {
case "LLM.RateLimit":
return { type: "provider.rate-limit", message: cause.message }
case "LLM.Authentication":
return { type: "provider.auth", message: cause.message }
case "LLM.PermissionDenied":
return { type: "provider.auth", message: cause.message }
case "LLM.NotFound":
return { type: "provider.not-found", message: cause.message }
case "LLM.QuotaExceeded":
return { type: "provider.quota", message: cause.message }
case "LLM.ContentPolicy":
return { type: "provider.content-filter", message: cause.message }
case "LLM.ContextOverflow":
return { type: "provider.context-overflow", message: cause.message }
case "LLM.ConnectionError":
return { type: "provider.transport", message: cause.message }
case "LLM.TimeoutError":
return { type: "provider.timeout", message: cause.message }
case "LLM.ServerError":
return { type: "provider.internal", message: cause.message }
case "LLM.MalformedResponse":
return { type: "provider.invalid-output", message: cause.message }
case "LLM.BadRequest":
return { type: "provider.invalid-request", message: cause.message }
case "LLM.NoRoute":
return { type: "provider.no-route", message: cause.message }
case "LLM.APIError":
return { type: "provider.unknown", message: cause.message }
default: {
const exhaustive: never = cause.reason
const exhaustive: never = cause
return exhaustive
}
}
+55 -49
View File
@@ -1,18 +1,22 @@
import { describe, expect, test } from "bun:test"
import {
AuthenticationReason,
ContentPolicyReason,
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
NoRouteReason,
APIError,
Authentication,
BadRequest,
ConnectionError,
ContentPolicy,
ContextOverflow,
MalformedResponse,
ModelID,
NoRoute,
NotFound,
PermissionDenied,
ProviderID,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
QuotaExceeded,
RateLimit,
RouteID,
ServerError,
TimeoutError,
ToolFailure,
} from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => {
test("maps every LLM reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
test("maps every LLM error tag to the open wire type", () => {
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
"provider.invalid-output",
)
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
expect(
toSessionError(
llm(
new NoRouteReason({
route: "route",
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
),
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
).type,
).toBe("provider.no-route")
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
})
test("preserves the permission rejection type without exposing internal fields", () => {
@@ -71,23 +69,31 @@ describe("toSessionError", () => {
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
new RateLimit({ message: "rate" }),
new ServerError({ message: "internal", status: 500 }),
new ConnectionError({ message: "reset" }),
new TimeoutError({ message: "timed out" }),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
llm(new QuotaExceededReason({ message: "quota" })),
llm(new ContentPolicyReason({ message: "blocked" })),
llm(new InvalidProviderOutputReason({ message: "output" })),
llm(new InvalidRequestReason({ message: "request" })),
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
llm(new UnknownProviderReason({ message: "unknown" })),
new Authentication({ message: "auth" }),
new PermissionDenied({ message: "forbidden" }),
new NotFound({ message: "missing" }),
new QuotaExceeded({ message: "quota" }),
new ContentPolicy({ message: "blocked" }),
new ContextOverflow({ message: "too long" }),
new MalformedResponse({ message: "output" }),
new BadRequest({ message: "request" }),
new NoRoute({
route: RouteID.make("route"),
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
new APIError({ message: "unknown" }),
]
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
})
})
+5 -12
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { LLMError, TransportReason } from "@opencode-ai/llm"
import { ConnectionError } from "@opencode-ai/llm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect(
SessionExecution.terminal(
Exit.fail(
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
type: "failed",
error: { type: "provider.transport", message: "Disconnected" },
})
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
type: "failed",
+67 -88
View File
@@ -1,15 +1,17 @@
import { describe, expect, test } from "bun:test"
import {
APIError,
BadRequest,
ConnectionError,
ContextOverflow,
LLMClient,
LLMError,
LLMEvent,
MalformedResponse,
Model,
RateLimit,
ToolFailure,
TransportReason,
InvalidProviderOutputReason,
InvalidRequestReason,
RateLimitReason,
type LLMClientShape,
type LLMError,
type LLMRequest,
} from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
@@ -69,8 +71,9 @@ import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined
let responses: ScriptedResponse[] | undefined
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
let streamGate: Deferred.Deferred<void> | undefined
@@ -93,9 +96,12 @@ const client = Layer.succeed(
responseStream = undefined
return stream
}
const scripted = responses === undefined ? response : (responses.shift() ?? [])
const events = streamFailure
? Stream.fail(streamFailure)
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
: Array.isArray(scripted)
? Stream.fromIterable(scripted)
: scripted
if (!streamGate) return events
return Stream.unwrap(
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
@@ -483,26 +489,16 @@ const setup = Effect.gen(function* () {
return yield* SessionV2.Service
})
const providerUnavailable = () =>
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
})
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
const invalidRequest = () =>
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({ message: "Invalid request" }),
})
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
const rateLimited = (retryAfterMs?: number) =>
new LLMError({
module: "test",
method: "stream",
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
})
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
const setupOverflowRecovery = Effect.gen(function* () {
const session = yield* setup
@@ -1614,14 +1610,14 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
responses = [
reply.text("Active complete", "text-active"),
reply.tool("call-active", "echo", { text: "active" }),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
reply.text("Steer complete", "text-steer"),
reply.text("Queue complete", "text-queue"),
@@ -1758,14 +1754,14 @@ describe("SessionRunnerLLM", () => {
yield* admit(session, "Earlier question")
yield* session.resume(sessionID)
response = [LLMEvent.providerError({ message: "summary unavailable" })]
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "provider.error", message: "summary unavailable" },
error: { type: "provider.unknown", message: "summary unavailable" },
})
}),
)
@@ -1875,7 +1871,7 @@ describe("SessionRunnerLLM", () => {
currentModel = compactModel
requests.length = 0
responses = [
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
reply.text("Must not run", "text-after-failed-compaction"),
]
yield* admit(session, "Recent exact request ".repeat(180))
@@ -1898,10 +1894,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
reply.text("## Objective\n- Recover overflow", "text-summary"),
reply.text("Recovered", "text-final"),
]
@@ -1928,7 +1921,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
currentModel = model
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
Stream.fail(contextOverflow()),
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
reply.text("Recovered", "text-final-unknown-limit"),
]
@@ -1948,7 +1941,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setupOverflowRecovery
currentModel = undersizedContextModel
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
Stream.fail(contextOverflow()),
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
reply.text("Recovered", "text-final-undersized-limit"),
]
@@ -1966,10 +1959,7 @@ describe("SessionRunnerLLM", () => {
it.effect("persists a second context overflow after one recovery", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
const overflow = () => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
yield* admit(session, "Continue")
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
@@ -1985,16 +1975,7 @@ describe("SessionRunnerLLM", () => {
it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
}),
)
responseStream = Stream.fail(contextOverflow())
responses = [
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
reply.text("Recovered", "text-final"),
@@ -2013,10 +1994,7 @@ describe("SessionRunnerLLM", () => {
it.effect("publishes the original overflow when recovery summarization fails", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
]
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
yield* admit(session, "Continue")
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
@@ -2027,7 +2005,7 @@ describe("SessionRunnerLLM", () => {
type: "compaction",
status: "failed",
reason: "auto",
error: { type: "provider.error", message: "summary unavailable" },
error: { type: "provider.unknown", message: "summary unavailable" },
}),
)
expect(context.slice(-3)).toMatchObject([
@@ -2041,10 +2019,7 @@ describe("SessionRunnerLLM", () => {
it.effect("interrupts overflow recovery while the summary provider is running", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
reply.text("## Objective\n- Interrupted", "text-summary"),
]
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
@@ -3627,7 +3602,10 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail durably")
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
responseStream = failingResponse(
[LLMEvent.stepStart({ index: 0 })],
new APIError({ message: "Provider unavailable" }),
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3644,7 +3622,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail before step")
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3732,13 +3710,15 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail after output")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
],
contextOverflow(),
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
expect(requests).toHaveLength(1)
@@ -3892,11 +3872,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call a malformed tool")
const failure = new LLMError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
})
const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
responseStream = Stream.fromIterable([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
@@ -3935,11 +3911,13 @@ describe("SessionRunnerLLM", () => {
toolExecutionGate = yield* Deferred.make<void>()
toolExecutionsStarted = yield* Deferred.make<void>()
toolExecutionsReady = 1
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
],
new APIError({ message: "Provider unavailable" }),
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(toolExecutionsStarted)
@@ -3966,11 +3944,10 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
yield* admit(session, "Fail hosted tool durably")
response = [
LLMEvent.stepStart({ index: 0 }),
hostedCall("call-hosted-provider-error", "effect"),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
responseStream = failingResponse(
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
new APIError({ message: "Provider unavailable" }),
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
@@ -3997,11 +3974,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Defect while provider fails")
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
LLMEvent.providerError({ message: "Provider unavailable" }),
]
responseStream = failingResponse(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
],
new APIError({ message: "Provider unavailable" }),
)
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
+14 -5
View File
@@ -1,12 +1,21 @@
---
title: "SDK"
description: "Embed an OpenCode host in an Effect application."
description: "Embed OpenCode directly in your application."
---
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
OpenCode server and routes API calls through its HTTP router in memory. It opens
no HTTP listener and adds no network hop between the client and server.
We're working on a general-purpose SDK for embedding OpenCode directly inside
your application. The regular SDK is coming soon.
An Effect-native version is available now for applications built with Effect.
Its current documentation is below. For other applications, run OpenCode as a
server and use the [TypeScript client](/build/client) in the meantime.
## Effect
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
[network client](/build/client), it assembles the OpenCode server and routes API
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
<Warning>
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
+3 -3
View File
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
@@ -291,7 +291,7 @@ Use this order for every protocol module:
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
+1 -1
View File
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
export { Auth } from "./route/auth"
export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
export type {
RouteModelInput,
RouteRoutedModelInput,
+6 -14
View File
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
import {
GenerationOptions,
HttpOptions,
InvalidProviderOutputReason,
LLMError,
MalformedResponse,
type LLMError,
LLMEvent,
LLMRequest,
LLMResponse,
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
)
if (!call || !LLMEvent.is.toolCall(call))
return yield* new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
}),
return yield* new MalformedResponse({
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
})
const object = yield* tool._decode(call.input).pipe(
Effect.mapError(
(error) =>
new LLMError({
module: "LLM",
method: "generateObject",
reason: new InvalidProviderOutputReason({
message: `generateObject: tool input failed schema decode: ${error.message}`,
}),
new MalformedResponse({
message: `generateObject: tool input failed schema decode: ${error.message}`,
}),
),
)
@@ -19,7 +19,7 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { isContextOverflow } from "../provider-error"
import { classifyApiFailure } from "../provider-error"
import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
return message || type || "Anthropic Messages stream error"
}
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
state,
[
LLMEvent.providerError({
message: providerErrorMessage(event),
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
}),
],
]
const onError = (event: AnthropicEvent) =>
classifyApiFailure({
message: providerErrorMessage(event),
code: event.error?.type,
})
const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "error") return Effect.succeed(onError(state, event))
if (event.type === "error") return Effect.fail(onError(event))
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+14 -21
View File
@@ -17,7 +17,7 @@ import {
type ToolResultPart,
} from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream"
import { isContextOverflow } from "../provider-error"
import { classifyApiFailure } from "../provider-error"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
}
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
const message =
event.internalServerException?.message ??
event.modelStreamErrorException?.message ??
event.serviceUnavailableException?.message ??
"Bedrock Converse stream error"
return [state, [LLMEvent.providerError({ message })]] as const
}
if (event.validationException || event.throttlingException) {
const message =
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
return [
state,
[
LLMEvent.providerError({
message,
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
}),
],
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
return yield* classifyApiFailure({
message: exception[1]?.message ?? "Bedrock Converse stream error",
code: exception[0],
})
}
return [state, []] as const
+10 -23
View File
@@ -19,7 +19,7 @@ import {
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { isContextOverflow } from "../provider-error"
import { classifyApiFailure } from "../provider-error"
import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure that emits a
// `provider-error`. All three end the stream — kept in one set so `step` and
// the protocol's `terminal` predicate stay in sync.
// `finish` event; `response.failed` is a hard failure that fails the stream
// with a classified `LLMError`. All three end the stream — kept in one set so
// `step` and the protocol's `terminal` predicate stay in sync.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
return message || code || fallback
}
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
return LLMEvent.providerError({
message,
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
classifyApiFailure({
message: providerErrorMessage(event, fallback),
code: event.code || event.error?.code || event.response?.error?.code || undefined,
})
}
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[providerError(event, "OpenAI Responses response failed")],
]
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
state,
[providerError(event, "OpenAI Responses stream error")],
]
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
if (event.type === "error") return Effect.succeed(onError(state, event))
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
+6 -16
View File
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
BadRequest,
MalformedResponse,
type LLMError,
type ContentPart,
type LLMRequest,
type MediaPart,
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
}
export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({
module: "ProviderShared",
method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }),
})
new MalformedResponse({ route, message, raw })
export const parseJson = (route: string, input: string, message: string) =>
Effect.try({
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* Canonical invalid-request constructor. Lift one-line `const invalid =
* (message) => invalidRequest(message)` aliases out of every
* route so the error constructor lives in one place. If we ever extend
* `InvalidRequestReason` with route context or trace metadata, the change
* lands here.
* `BadRequest` with route context or trace metadata, the change lands here.
*/
export const invalidRequest = (message: string) =>
new LLMError({
module: "ProviderShared",
method: "request",
reason: new InvalidRequestReason({ message }),
})
export const invalidRequest = (message: string) => new BadRequest({ message })
export const matchToolChoice = <Auto, None, Required, Tool>(
route: string,
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
}
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
result instanceof LLMError
isLLMError(result)
/**
* Register a tool call whose start event arrived before any argument deltas.
+115 -6
View File
@@ -1,5 +1,19 @@
import { Schema } from "effect"
import { LLMError, ProviderErrorEvent } from "./schema"
import {
APIError,
Authentication,
BadRequest,
ContentPolicy,
ContextOverflow,
HttpContext,
HttpRateLimitDetails,
NotFound,
PermissionDenied,
ProviderMetadata,
QuotaExceeded,
RateLimit,
ServerError,
type LLMError,
} from "./schema"
const patterns = [
/prompt is too long/i,
@@ -27,7 +41,102 @@ const patterns = [
export const isContextOverflow = (message: string) =>
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
overloaded_error: serverError,
api_error: serverError,
server_error: serverError,
internal_error: serverError,
server_is_overloaded: serverError,
internalServerException: serverError,
serviceUnavailableException: serverError,
modelStreamErrorException: serverError,
rate_limit_error: rateLimit,
rate_limit_exceeded: rateLimit,
too_many_requests: rateLimit,
throttlingException: rateLimit,
authentication_error: (_input, common) => new Authentication(common),
permission_error: (_input, common) => new PermissionDenied(common),
not_found_error: (_input, common) => new NotFound(common),
invalid_request_error: (_input, common) => new BadRequest(common),
invalid_prompt: (_input, common) => new BadRequest(common),
validationException: (_input, common) => new BadRequest(common),
}
export interface ApiFailure {
readonly message: string
readonly status?: number | undefined
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
readonly code?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly requestID?: string | undefined
readonly http?: HttpContext | undefined
readonly providerMetadata?: ProviderMetadata | undefined
}
type CommonFields = {
readonly message: string
readonly status: number | undefined
readonly code: string | undefined
readonly requestID: string | undefined
readonly http: HttpContext | undefined
readonly providerMetadata: ProviderMetadata | undefined
}
function serverError(input: ApiFailure, common: CommonFields) {
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
}
function rateLimit(input: ApiFailure, common: CommonFields) {
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
}
/**
* One classifier for every failure a remote API deliberately reports.
* Protocols call it with in-stream error payloads, the request executor with
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
* three surfaces produce identical `LLMError` tags.
*
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
* HTTP status, provider code, then the generic `APIError` fallback.
*/
export const classifyApiFailure = (input: ApiFailure): LLMError => {
const common: CommonFields = {
message: input.message,
status: input.status,
code: input.code,
requestID: input.requestID,
http: input.http,
providerMetadata: input.providerMetadata,
}
const body = input.http?.body ?? ""
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
if (
clientScoped &&
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
isContextOverflow(input.message) ||
(body.length > 0 && isContextOverflow(body)))
)
return new ContextOverflow(common)
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
if (input.status === 401) return new Authentication(common)
if (input.status === 403) return new PermissionDenied(common)
if (input.status === 404) return new NotFound(common)
if (input.status === 429) {
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
return rateLimit(input, common)
}
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
return new BadRequest(common)
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
if (byCode) return byCode(input, common)
return new APIError(common)
}
+4 -11
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
@@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
}
const toLLMError = (error: AuthError): LLMError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new LLMError({
module: "Auth",
method: "apply",
reason:
error instanceof MissingCredentialError
? new AuthenticationReason({ message: error.message, kind: "missing" })
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
})
}
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
if (error instanceof Config.ConfigError)
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
return error
}
+2 -2
View File
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
import {
GenerationOptions,
HttpOptions,
isLLMError,
LLMRequest,
LLMResponse,
Model,
ModelLimits,
LLMError as LLMErrorClass,
PreparedRequest,
ProviderID,
mergeGenerationOptions,
@@ -225,7 +225,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof LLMErrorClass) return failed
if (failed !== undefined && isLLMError(failed)) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
+46 -92
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer } from "effect"
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import {
FetchHttpClient,
Headers,
@@ -8,21 +8,15 @@ import {
HttpClientResponse,
} from "effect/unstable/http"
import {
AuthenticationReason,
ContentPolicyReason,
ConnectionError,
HttpContext,
HttpRateLimitDetails,
HttpRequestDetails,
HttpResponseDetails,
InvalidRequestReason,
LLMError,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
TimeoutError,
type LLMError,
} from "../schema"
import { isContextOverflow } from "../provider-error"
import { classifyApiFailure } from "../provider-error"
export interface Interface {
readonly execute: (
@@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
)
}
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
@@ -219,56 +211,21 @@ const responseHttp = (input: {
rateLimit: input.rateLimit,
})
const statusReason = (input: {
readonly status: number
readonly message: string
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http: HttpContext
}) => {
const body = input.http.body ?? ""
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
return new ContentPolicyReason({ message: input.message, http: input.http })
}
if (input.status === 401) {
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
}
if (input.status === 403) {
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
}
if (input.status === 429) {
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
return new QuotaExceededReason({ message: input.message, http: input.http })
}
return new RateLimitReason({
message: input.message,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
http: input.http,
})
}
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
) {
return new InvalidRequestReason({
message: input.message,
classification: isContextOverflow(body) ? "context-overflow" : undefined,
http: input.http,
})
}
if (input.status >= 500 || providerInternalStatus(input.status)) {
return new ProviderInternalReason({
message: input.message,
status: input.status,
retryAfterMs: input.retryAfterMs,
http: input.http,
})
}
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// Provider machine code from a JSON error body (`error.code` / `error.type`),
// fed to the shared classifier so code-based rules (overflow, quota) work on
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
const providerCode = (body: string | undefined) => {
if (!body) return undefined
const decoded = Option.getOrUndefined(decodeBodyJson(body))
if (typeof decoded !== "object" || decoded === null) return undefined
const error = (decoded as Record<string, unknown>).error
if (typeof error !== "object" || error === null) return undefined
const fields = error as Record<string, unknown>
if (typeof fields.code === "string") return fields.code
if (typeof fields.type === "string") return fields.type
return undefined
}
const statusError =
@@ -281,58 +238,55 @@ const statusError =
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request)
return yield* new LLMError({
module: "RequestExecutor",
method: "execute",
reason: statusReason({
status: response.status,
message: providerMessage(response.status, details),
retryAfterMs: retryAfter,
return yield* classifyApiFailure({
status: response.status,
message: providerMessage(response.status, details),
code: providerCode(details.body),
retryAfterMs: retryAfter,
rateLimit,
requestID: requestId(headers),
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit,
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit,
}),
}),
})
})
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
const connectionError = (input: {
readonly message: string
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
new LLMError({
module: "RequestExecutor",
method: "execute",
reason: new TransportReason({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
}),
new ConnectionError({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: httpContext(input.request),
cause: error,
})
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
return new TimeoutError({ message: error.message })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" })
return connectionError({ message: "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
return transportError({
return connectionError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
request,
})
}
return transportError({
return connectionError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
request,
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema"
import { ConnectionError, type LLMError } from "../../schema"
import * as HttpTransport from "./http"
import type { Transport } from "./index"
@@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
const transportError = (
method: string,
_method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
) =>
new LLMError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
})
) => new ConnectionError({ message, url: input.url, kind: input.kind })
const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message
+110 -81
View File
@@ -1,9 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
method: Schema.String,
url: Schema.String,
@@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"),
/**
* Fields shared by every failure the remote API deliberately reported —
* whether as a non-2xx response, an SSE error event, a WebSocket error
* message, or a binary exception frame. `status` is absent when the error
* arrived mid-stream without an HTTP status; `code` carries the provider's
* machine-readable error code (e.g. `context_length_exceeded`) when one
* exists.
*/
const apiFailureFields = {
message: Schema.String,
parameter: Schema.optional(Schema.String),
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
status: Schema.optional(Schema.Number),
code: Schema.optional(Schema.String),
requestID: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
_tag: Schema.tag("NoRoute"),
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
}
providerMetadata: Schema.optional(ProviderMetadata),
}
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
_tag: Schema.tag("Authentication"),
message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
...apiFailureFields,
parameter: Schema.optional(Schema.String),
}) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
_tag: Schema.tag("RateLimit"),
message: Schema.String,
/** Credentials are missing, invalid, or expired (401). */
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
...apiFailureFields,
}) {}
/** Authenticated but not allowed (403). */
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
...apiFailureFields,
}) {}
/** Model or endpoint does not exist (404). */
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
...apiFailureFields,
}) {}
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
...apiFailureFields,
retryAfterMs: Schema.optional(Schema.Number),
rateLimit: Schema.optional(HttpRateLimitDetails),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
...apiFailureFields,
}) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"),
message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
/** Provider refused the content for policy/safety reasons. */
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
...apiFailureFields,
}) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"),
message: Schema.String,
status: Schema.Number,
/**
* The request exceeds the model's context window. Designated tag because
* Core recovers from it structurally (compaction) rather than surfacing it.
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
*/
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
...apiFailureFields,
}) {}
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
...apiFailureFields,
retryAfterMs: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
_tag: Schema.tag("Transport"),
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
...apiFailureFields,
}) {}
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
message: Schema.String,
kind: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
cause: Schema.optional(Schema.Defect()),
}) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"LLM.Error.InvalidProviderOutput",
)({
_tag: Schema.tag("InvalidProviderOutput"),
/** The request or stream read timed out before the provider answered. */
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
message: Schema.String,
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
/**
* Transport succeeded but the content broke the protocol contract:
* undecodable frames, premature EOF without a terminal `finish`, duplicate
* terminals, or output after a terminal event.
*/
export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
message: Schema.String,
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"),
message: Schema.String,
status: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext),
}) {}
export const LLMErrorReason = Schema.Union([
InvalidRequestReason,
NoRouteReason,
AuthenticationReason,
RateLimitReason,
QuotaExceededReason,
ContentPolicyReason,
ProviderInternalReason,
TransportReason,
InvalidProviderOutputReason,
UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag"))
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
module: Schema.String,
method: Schema.String,
reason: LLMErrorReason,
/** Request construction failed locally: the selected model resolves to no executable route. */
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
route: RouteID,
provider: ProviderID,
model: ModelID,
}) {
override readonly cause = this.reason
override get message() {
return `${this.module}.${this.method}: ${this.reason.message}`
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
}
}
const members = [
BadRequest,
Authentication,
PermissionDenied,
NotFound,
RateLimit,
QuotaExceeded,
ContentPolicy,
ContextOverflow,
ServerError,
APIError,
ConnectionError,
TimeoutError,
MalformedResponse,
NoRoute,
] as const
export const LLMErrorSchema = Schema.Union(members)
/**
* Every failure of one LLM request. `LLMEvent` streams carry output only;
* all failures — HTTP rejections, in-stream provider error events, transport
* failures, and protocol-contract violations — exit through this union on
* the stream's error channel.
*/
export type LLMError = typeof LLMErrorSchema.Type
export const isLLMError = (value: unknown): value is LLMError =>
members.some((member) => value instanceof member)
/**
* Failure type for tool execute handlers. Handlers must map their internal
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
+1 -20
View File
@@ -2,7 +2,6 @@ import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
/**
* Token usage reported by an LLM provider.
@@ -197,14 +196,6 @@ export const Finish = Schema.Struct({
}).annotate({ identifier: "LLM.Event.Finish" })
export type Finish = Schema.Schema.Type<typeof Finish>
export const ProviderErrorEvent = Schema.Struct({
type: Schema.tag("provider-error"),
message: Schema.String,
classification: Schema.optional(ProviderFailureClassification),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ProviderError" })
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
StepStart,
TextStart,
@@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([
ToolError,
StepFinish,
Finish,
ProviderErrorEvent,
]).pipe(Schema.toTaggedUnion("type"))
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
@@ -271,7 +261,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
...input,
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
}),
providerError: ProviderErrorEvent.make,
is: {
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
@@ -288,7 +277,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
toolError: llmEventTagged.guards["tool-error"],
stepFinish: llmEventTagged.guards["step-finish"],
finish: llmEventTagged.guards.finish,
providerError: llmEventTagged.guards["provider-error"],
},
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
@@ -374,13 +362,6 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
finishReason: event.reason,
}
}
if (LLMEvent.is.providerError(event)) {
return {
...state,
events,
finishReason: state.finishReason ?? "error",
}
}
return {
...state,
events,
@@ -589,7 +570,7 @@ export namespace LLMResponse {
/** Purely fold one provider-neutral event into the attempt assembly state. */
export const reduce = reduceResponseState
/** Return a completed response only after a terminal finish or provider error. */
/** Return a completed response only after a terminal finish event. */
export const complete = (state: State): LLMResponse | undefined =>
state.finishReason === undefined
? undefined
+31 -35
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMError } from "../src"
import { LLM, isLLMError, type LLMError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http"
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
)
const expectLLMError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
expect(isLLMError(error)).toBe(true)
if (!isLLMError(error)) throw new Error("expected LLMError")
return error
}
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
describe("RequestExecutor", () => {
it.effect("classifies context overflow responses", () =>
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
}).pipe(
Effect.provide(
responsesLayer([
@@ -91,8 +91,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
@@ -102,8 +101,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
@@ -114,24 +112,22 @@ describe("RequestExecutor", () => {
expectLLMError(error)
expect(error).toMatchObject({
reason: {
_tag: "RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
_tag: "LLM.RateLimit",
retryAfterMs: 0,
rateLimit: { retryAfterMs: 0 },
http: {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
},
},
},
@@ -169,8 +165,8 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
retryAfterMs: 0,
limit: { requests: "500", tokens: "30000" },
remaining: { requests: "499", tokens: "29900" },
@@ -202,7 +198,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0,
limit: { requests: "100", "input-tokens": "10000" },
@@ -245,12 +241,12 @@ describe("RequestExecutor", () => {
)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("marks 504 and 529 status responses as provider-internal", () =>
it.effect("marks 504 and 529 status responses as server errors", () =>
Effect.gen(function* () {
const failWith = (status: number) =>
Effect.gen(function* () {
@@ -258,7 +254,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
}).pipe(
Effect.provide(
responsesLayer([
@@ -281,7 +277,7 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384)
}).pipe(
@@ -360,7 +356,7 @@ describe("RequestExecutor", () => {
)
expectLLMError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
+2 -2
View File
@@ -149,8 +149,8 @@ describe("request option precedence", () => {
}),
).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
expect(error).toMatchObject({
_tag: "LLM.BadRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic"
import { weatherToolName } from "../recorded-scenarios"
@@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({
Message.user("Use that result to answer briefly."),
],
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
// The cassette predates the `cache: "auto"` default; pin the policy off so
// the replayed request matches the recorded wire shape.
cache: "none",
})
const recorded = recordedTests({
@@ -33,13 +36,17 @@ const recorded = recordedTests({
})
describe("Anthropic Messages sad-path recorded", () => {
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
recorded.effect.with(
"rejects malformed assistant tool order",
// The cassette predates a test rename; keep replaying the existing recording.
{ id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
}),
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
})
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
@@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("emits provider-error events for mid-stream provider errors", () =>
it.effect("fails the stream for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
),
Effect.flip,
)
// Prefix the error type so consumers can distinguish overloads, rate
// limits, and quota errors without parsing the message string.
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
}),
)
it.effect("classifies prompt-too-long provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "invalid_request_error: prompt is too long: 210000 tokens",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "invalid_request_error: prompt is too long: 210000 tokens",
})
}),
)
it.effect("falls back to error type when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
}),
)
it.effect("falls back to a stable default when error payload is absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
}),
)
@@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
import { LLMClient } from "../../src/route"
import { AmazonBedrock } from "../../src/providers"
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
@@ -355,33 +355,31 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits provider-error for throttlingException", () =>
it.effect("fails the stream for throttlingException", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["throttlingException", { message: "Slow down" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
type: "provider-error",
message: "Slow down",
})
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
}),
)
it.effect("classifies input-too-long validation exceptions", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
),
Effect.flip,
)
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
type: "provider-error",
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "Input is too long for requested model",
classification: "context-overflow",
})
}),
)
+3 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { ProviderShared } from "../../src/protocols/shared"
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
expect(error.message).toContain("Invalid google/gemini stream event")
}),
)
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -662,8 +662,8 @@ describe("OpenAI Chat route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits provider-error events for mid-stream provider errors", () =>
it.effect("fails the stream for mid-stream provider errors", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
Effect.flip,
)
// Prefix the code so consumers see the failure mode, not just the
// sometimes-generic provider message. The bare message alone meant
// production errors like rate limits were indistinguishable from
// unrelated stream failures.
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
}),
)
it.effect("falls back to error code when no message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
}),
)
it.effect("falls back to error code when message is empty", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
}),
)
@@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => {
// "OpenAI Responses response failed" string, hiding the real cause.
it.effect("surfaces response.failed details from response.error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
}),
)
@@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => {
// when they bubble up an HTTP error as an SSE `error` event. Honour
// both shapes so the user still sees the underlying cause instead
// of the catch-all string.
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
}),
)
it.effect("surfaces error event details nested under error", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([
{
type: "provider-error",
message: "context_length_exceeded: prompt too long",
classification: "context-overflow",
},
])
expect(error).toMatchObject({
_tag: "LLM.ContextOverflow",
message: "context_length_exceeded: prompt too long",
})
}),
)
it.effect("accepts nullable fields in spec-compliant error events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
@@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => {
}),
),
),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
}),
)
it.effect("falls back to a stable default when error is null", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
}),
)
@@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => {
Effect.flip,
)
expect(error).toBeInstanceOf(LLMError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(isLLMError(error)).toBe(true)
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
expect(error.message).toContain("HTTP 400")
}),
)
+4 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLMError } from "../src/schema"
import { isLLMError } from "../src/schema"
import { ToolStream } from "../src/protocols/utils/tool-stream"
import { it } from "./lib/effect"
@@ -40,8 +40,9 @@ describe("ToolStream", () => {
Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(LLMError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
expect(isLLMError(error)).toBe(true)
if (ToolStream.isError(error))
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
}),
)
@@ -418,9 +418,6 @@ const layer = Layer.effect(
return
}
case "provider-error":
throw new Error(value.message)
case "step-start":
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
yield* session.updatePart({
@@ -136,14 +136,14 @@ test("theme swaps restyle active reasoning without resetting the stream", async
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: previousSyntax,
syntax: previousSyntax,
},
}
const next = {
...RUN_THEME_FALLBACK,
block: {
...RUN_THEME_FALLBACK.block,
subtleSyntax: nextSyntax,
syntax: nextSyntax,
},
}
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
@@ -67,9 +67,7 @@ test("returns syntax styles and indexed splash colors", async () => {
try {
expect(theme.block.syntax).toBeDefined()
expect(theme.block.subtleSyntax).toBeDefined()
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
expectIndexed(theme.splash.left)
expectIndexed(theme.splash.right)
expectIndexed(theme.splash.leftShadow)
@@ -82,7 +80,6 @@ test("returns syntax styles and indexed splash colors", async () => {
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
@@ -103,7 +100,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
expectIndexed(theme.block.warning)
} finally {
theme.block.syntax?.destroy()
theme.block.subtleSyntax?.destroy()
}
})
@@ -119,9 +115,7 @@ test("uses refreshed background brightness when cached renderer mode is stale",
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
} finally {
stale.block.syntax?.destroy()
stale.block.subtleSyntax?.destroy()
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
}
})
@@ -138,9 +132,7 @@ test("keeps renderer mode when refreshed default background is unavailable", asy
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
} finally {
light.block.syntax?.destroy()
light.block.subtleSyntax?.destroy()
dark.block.syntax?.destroy()
dark.block.subtleSyntax?.destroy()
}
})
@@ -219,8 +219,7 @@ const fragmentFailureLLM = Layer.succeed(
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
LLMEvent.textStart({ id: "text-1" }),
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
LLMEvent.providerError({ message: "provider boom" }),
),
).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
}),
)
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
-1
View File
@@ -23,7 +23,6 @@
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx",
"./context/sync": "./src/context/sync.tsx",
"./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
+3 -7
View File
@@ -44,7 +44,6 @@ import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync"
import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location"
import { LocalProvider, useLocal } from "./context/local"
@@ -345,8 +344,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
<DataProvider>
<DataProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
@@ -376,8 +374,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</DataProvider>
</SyncProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
@@ -430,7 +427,6 @@ function App(props: {
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState
@@ -480,7 +476,7 @@ function App(props: {
routes: pluginRuntime.routes,
event,
sdk,
sync,
project,
data,
theme: themeState,
toast,
+1 -1
View File
@@ -38,7 +38,7 @@ type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const DEFAULT_TITLE = "opencode"
const DEFAULT_TITLE = "OpenCode"
const DEFAULT_PACK_ID = "opencode.default"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
@@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
{
command: "model.dialog.provider",
title: connected() ? "Connect integration" : "View all integrations",
selection: "none",
onTrigger() {
dialog.replace(() => (
<DialogIntegration
@@ -345,6 +345,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: () => void create(),
},
{
@@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
{
command: "dialog.move_session.refresh",
title: "refresh",
selection: "none",
onTrigger: () => void refetch(),
},
]
@@ -26,7 +26,6 @@ export function DialogSessionList() {
const sdk = useSDK()
const local = useLocal()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
@@ -56,16 +55,11 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
const query = filter()
const query = search()
if (!query) return data.session.list()
const result = searchResults()
return result?.query === query ? result.sessions : []
})
const searching = createMemo(() => {
const query = filter()
if (!query) return false
return query !== search() || searchResults.loading || searchResults()?.query !== query
})
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
@@ -124,18 +118,9 @@ export function DialogSessionList() {
<DialogSelect
title="Sessions"
options={options()}
loading={searching()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No sessions yet</text>
</box>
}
skipFilter={true}
current={currentSessionID()}
onFilter={(query) => {
setFilter(query)
setSearch(query)
}}
onFilter={setSearch}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
+1 -6
View File
@@ -57,7 +57,6 @@ export function DialogSkill(props: DialogSkillProps) {
<DialogSelect
title="Skills"
options={options()}
loading={skills.loading}
renderFilter={!showError()}
locked={showError()}
emptyView={
@@ -68,11 +67,7 @@ export function DialogSkill(props: DialogSkillProps) {
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No skills available</text>
</box>
)
) : undefined
}
/>
)
@@ -40,7 +40,6 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
<DialogSelect
title="Autocomplete"
options={options()}
loading={files.loading}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
@@ -108,7 +108,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
{/* Headline */}
<box flexDirection="column" alignItems="center" flexShrink={0}>
<text attributes={TextAttributes.BOLD} fg={colors.text}>
opencode crashed
OpenCode crashed
</text>
<Show when={showSubtext()}>
<text fg={colors.muted}>An unexpected error stopped the session.</text>
@@ -192,7 +192,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
? "Report copied — paste it into a new GitHub issue."
: "Copy the report and open a GitHub issue to help us fix this."}
</text>
<text fg={colors.muted}>opencode {InstallationVersion}</text>
<text fg={colors.muted}>OpenCode {InstallationVersion}</text>
</box>
</Show>
</box>
@@ -211,7 +211,7 @@ function buildIssueURL(message: string, stack: string) {
url.searchParams.set("terminal", describeTerminal())
url.searchParams.set(
"reproduce",
"Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
"Reported automatically from the OpenCode crash screen. If you can, describe what you were doing when it crashed.",
)
// Budget the stack against the fully URL-encoded length (not the raw length) so
@@ -220,7 +220,7 @@ function buildIssueURL(message: string, stack: string) {
// so measuring url.toString() is both correct and safe on any input.
const MAX_URL_LENGTH = 6000
const marker = "\n... (truncated)"
const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
setBody(stack)
-94
View File
@@ -1,94 +0,0 @@
import type {
Agent,
Command,
Config,
FormatterStatus,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
PermissionRequest,
Provider,
QuestionRequest,
Session,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useProject } from "./project"
export const {
context: SyncContext,
use: useSync,
provider: SyncProvider,
} = createSimpleContext({
name: "Sync",
init: () => {
const project = useProject()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
agent: Agent[]
command: Command[]
permission: Record<string, PermissionRequest[]>
question: Record<string, QuestionRequest[]>
config: Config
session: Session[]
session_diff: Record<string, FileDiffInfo[]>
message: Record<string, Message[]>
part: Record<string, Part[]>
lsp: LspStatus[]
mcp: Record<string, McpStatus>
mcp_resource: Record<string, McpResource>
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
status: "complete",
provider: [],
agent: [],
command: [],
permission: {},
question: {},
config: {},
session: [],
session_diff: {},
message: {},
part: {},
lsp: [],
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
})
return {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
return true
},
get path() {
return project.instance.path()
},
session: {
get(_sessionID: string) {
return undefined as Session | undefined
},
query() {
return {} as { scope?: "project"; path?: string }
},
async refresh() {},
status(_sessionID: string) {
return "idle" as const
},
async sync(_sessionID: string) {},
},
async bootstrap(_input: { fatal?: boolean } = {}) {},
}
},
})
-5
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
@@ -63,7 +62,6 @@ export {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
@@ -75,7 +73,6 @@ export {
upsertTheme,
type Theme,
type ThemeJson,
type SyntaxStyleOverrides,
} from "../theme"
const THEME_REFRESH_DELAYS = [250, 1000] as const
@@ -277,7 +274,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
return {
theme: new Proxy(values(), {
@@ -292,7 +288,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
all: allThemes,
has: hasTheme,
syntax,
subtleSyntax,
mode: () => store.mode,
locked: () => store.lock !== undefined,
lock: () => pin(store.mode),
@@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) {
{
title: "install",
command: "dialog.plugins.install",
selection: "none",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
+21 -27
View File
@@ -3,8 +3,8 @@ import type { Config } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync"
import type { useData } from "../context/data"
import type { useProject } from "../context/project"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
@@ -29,7 +29,7 @@ type Input = {
routes: PluginRoutes
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
@@ -95,57 +95,51 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return true
},
get config() {
return sync.data.config
return {}
},
get provider() {
return sync.data.provider
return []
},
get path() {
return sync.path
return project.instance.path()
},
get vcs() {
if (!sync.data.vcs) return
return {
branch: sync.data.vcs.branch,
default_branch: sync.data.vcs.default_branch,
}
return undefined
},
session: {
count() {
return data.session.list().length
},
get(sessionID) {
return sync.session.get(sessionID)
get(_sessionID) {
return undefined
},
diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }],
)
diff(_sessionID) {
return []
},
messages(sessionID) {
return sync.data.message[sessionID] ?? []
messages(_sessionID) {
return []
},
status(sessionID) {
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(sessionID) {
return sync.data.permission[sessionID] ?? []
permission(_sessionID) {
return []
},
question(sessionID) {
return sync.data.question[sessionID] ?? []
question(_sessionID) {
return []
},
},
part(messageID) {
return sync.data.part[messageID] ?? []
part(_messageID) {
return []
},
lsp() {
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
return []
},
mcp() {
return (data.location.mcp.server.list() ?? [])
@@ -297,7 +291,7 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
set() {},
ready: true,
},
state: stateApi(input.sync, input.data),
state: stateApi(input.project, input.data),
get client() {
return input.sdk.client
},
+2 -13
View File
@@ -23,7 +23,7 @@ import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
import { useTheme } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
@@ -1716,7 +1716,7 @@ function ReasoningPart(props: {
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -1736,8 +1736,6 @@ function ReasoningPart(props: {
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
@@ -2398,10 +2396,6 @@ function Subagent(props: ToolProps) {
)
}
export function formatSubagentToolcalls(count: number) {
return `${count} toolcall${count === 1 ? "" : "s"}`
}
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
}
@@ -2410,11 +2404,6 @@ export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
export function formatCompletedSubagentDetail(toolcalls: number, duration: string) {
if (toolcalls === 0) return duration
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] {
+3 -1
View File
@@ -177,7 +177,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const queuedStart = (rows: SessionRow[]) => {
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
const index = rows.findIndex(
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
)
return index === -1 ? rows.length : index
}
-27
View File
@@ -90,7 +90,6 @@ export type Theme = {
_hasSelectedListItemText: boolean
}
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
export type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
// If theme explicitly defines selectedListItemText, use it
@@ -557,32 +556,6 @@ export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
}
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
const rules = getSyntaxRules(theme)
return SyntaxStyle.fromTheme(
rules.map((rule) => {
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
if (rule.style.foreground) {
const fg = rule.style.foreground
return {
...rule,
style: {
...rule.style,
...override,
foreground: RGBA.fromInts(
Math.round(fg.r * 255),
Math.round(fg.g * 255),
Math.round(fg.b * 255),
Math.round(theme.thinkingOpacity * 255),
),
},
}
}
return rule
}),
)
}
function getSyntaxRules(theme: Theme) {
return [
{
+41 -47
View File
@@ -26,7 +26,6 @@ export interface DialogSelectProps<T> {
placeholder?: string
footer?: JSX.Element
emptyView?: JSX.Element
loading?: boolean
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
@@ -37,14 +36,7 @@ export interface DialogSelectProps<T> {
renderFilter?: boolean
locked?: boolean
preserveSelection?: boolean
actions?: {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
onTrigger: (option: DialogSelectOption<T>) => void
}[]
actions?: DialogSelectAction<T>[]
footerHints?: {
title: string
label: string
@@ -55,6 +47,24 @@ export interface DialogSelectProps<T> {
focusCurrent?: boolean
}
type DialogSelectActionBase<T> = {
command: string
title: string
side?: "left" | "right"
hidden?: boolean
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
}
type DialogSelectAction<T> =
| (DialogSelectActionBase<T> & {
selection?: "required"
onTrigger: (option: DialogSelectOption<T>) => void
})
| (DialogSelectActionBase<T> & {
selection: "none"
onTrigger: () => void
})
export interface DialogSelectOption<T = any> {
title: string
titleView?: JSX.Element
@@ -223,7 +233,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
on(
() => props.options,
() => {
if (!props.preserveSelection) return
if (!props.preserveSelection) {
const next = Math.min(store.selected, flat().length - 1)
if (next >= 0 && next !== store.selected) setStore("selected", next)
return
}
if (resetSelection && store.filter.length > 0) {
const option = flat()[0]
if (!option) return
@@ -351,7 +365,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "keyboard")
const index = focusedAction()
if (index !== undefined) {
triggerAction(actionItems()[index])
trigger(actionItems()[index])
return
}
const option = selected()
@@ -442,14 +456,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
name: item.command,
title: item.title,
category: "Dialog",
run() {
if (props.locked) return
if (isActionDisabled(item)) return
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
run: () => trigger(item),
})),
],
bindings: [
@@ -505,10 +512,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
function triggerAction(item: VisibleAction | undefined) {
if (props.locked) return
if (!item || !isActionItem(item) || isActionDisabled(item)) return
function trigger(item: Action | undefined) {
if (props.locked || !item || isActionDisabled(item)) return
setStore("input", "keyboard")
if (item.selection === "none") {
item.onTrigger()
return
}
const option = selected()
if (!option) return
item.onTrigger(option)
@@ -519,7 +529,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
function isActionDisabled(item: Action) {
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
const option = selected()
if (item.selection !== "none" && !option) return true
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
}
function isActionFocused(item: VisibleAction) {
@@ -546,7 +558,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
flexDirection="row"
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseUp={() => triggerAction(item)}
onMouseUp={() => trigger(item)}
>
<text
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
@@ -604,29 +616,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show
when={grouped().length > 0}
fallback={
<Show
when={!props.loading}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading...</text>
</box>
}
>
<Show
when={store.filter.length === 0}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No matching results</text>
</box>
}
>
{props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No items</text>
</box>
)}
</Show>
</Show>
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)
}
>
<scrollbox
@@ -1,65 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../../src/context/args"
import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync"
import { PermissionProvider } from "../../../../src/context/permission"
import { ExitProvider } from "../../../../src/context/exit"
import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
export async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
type Ctx = { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
export async function mount(override?: FetchHandler, state?: string) {
const events = createEventStream()
const calls = createFetch(override, events)
let sync!: ReturnType<typeof useSync>
let project!: ReturnType<typeof useProject>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
function Probe() {
const ctx: Ctx = { project: useProject(), sync: useSync() }
onMount(() => {
sync = ctx.sync
project = ctx.project
done()
})
return <box />
}
const app = await testRender(() => (
<TestTuiContexts paths={state ? { state } : undefined}>
<ArgsProvider>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<PermissionProvider>
<ProjectProvider>
<ExitProvider exit={() => {}}>
<SyncProvider>
<Probe />
</SyncProvider>
</ExitProvider>
</ProjectProvider>
</PermissionProvider>
</SDKProvider>
</ArgsProvider>
</TestTuiContexts>
))
await ready
await wait(() => sync.status === "complete")
return { app, emit: events.emit, project, sync, session: calls.session }
}
@@ -1,24 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { mount } from "./sync-fixture"
test("legacy sync is an inert compatibility context", async () => {
const { app, session, sync } = await mount()
try {
expect(sync.status).toBe("complete")
expect(sync.ready).toBe(true)
expect(sync.data.session).toEqual([])
expect(sync.data.message).toEqual({})
expect(sync.data.provider).toEqual([])
expect(sync.session.get("ses_test")).toBeUndefined()
await sync.bootstrap()
await sync.session.refresh()
await sync.session.sync("ses_test")
expect(session).toEqual([])
} finally {
app.renderer.destroy()
}
})
+15
View File
@@ -1202,6 +1202,21 @@ test("restores queued compaction from durable pending input", async () => {
{ type: "compaction-queued", inputID: "message-compaction-later" },
])
emitEvent(events, {
id: "evt_text_ended",
created: 2,
type: "session.text.ended",
durable: durable(sessionID, 5),
data: {
sessionID,
assistantMessageID: "message-assistant",
ordinal: 0,
text: "Active output",
},
})
await wait(() => rows.some((row) => row.type === "part"))
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
emitEvent(events, {
id: "evt_compaction_started",
created: 2,
@@ -0,0 +1,264 @@
/** @jsxImportSource @opentui/solid */
import { InputRenderable } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createSignal, onCleanup, onMount } from "solid-js"
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
import { tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function renderSelect(
root: string,
options: DialogSelectOption<string>[],
onGlobal: () => void,
onRow: (option: DialogSelectOption<string>) => void,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<DialogSelect
title="Items"
options={options}
actions={[
{
command: "dialog.move_session.delete",
title: "delete",
onTrigger: onRow,
},
{
command: "dialog.move_session.new",
title: "new",
selection: "none",
onTrigger: onGlobal,
},
]}
/>
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Items"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return app
}
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const config = createTuiResolvedConfig()
const [
{ ConfigProvider },
{ ThemeProvider },
{ OpencodeKeymapProvider, registerOpencodeKeymap },
{ DialogProvider, useDialog },
{ DialogSelect },
{ ToastProvider },
] = await Promise.all([
import("../../../src/config"),
import("../../../src/context/theme"),
import("../../../src/keymap"),
import("../../../src/ui/dialog"),
import("../../../src/ui/dialog-select"),
import("../../../src/ui/toast"),
])
const selected: string[] = []
const moved: string[] = []
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
const [options, setOptions] = createSignal(initial)
replaceOptions = setOptions
onCleanup(off)
function Fixture() {
const dialog = useDialog()
onMount(() =>
dialog.replace(() => (
<DialogSelect
title="Mutable options"
options={options()}
onMove={(option) => moved.push(option.value)}
onSelect={(option) => selected.push(option.value)}
/>
)),
)
return null
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 24, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Mutable options"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
return { app, moved, replaceOptions, selected }
}
test("dialog actions run without options while row actions still require a selection", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[],
() => global++,
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("d", { ctrl: true })
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("footer actions run when filtering leaves no selected row", async () => {
await using tmp = await tmpdir()
let global = 0
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => global++,
(option) => rows.push(option.value),
)
try {
for (const key of "missing") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("No results found"))
app.mockInput.pressKey("d", { ctrl: true })
app.mockInput.pressTab()
app.mockInput.pressEnter()
expect(global).toBe(1)
expect(rows).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("row actions receive the selected option", async () => {
await using tmp = await tmpdir()
const rows: string[] = []
const app = await renderSelect(
tmp.path,
[{ title: "Alpha", value: "alpha" }],
() => {},
(option) => rows.push(option.value),
)
try {
app.mockInput.pressKey("d", { ctrl: true })
expect(rows).toEqual(["alpha"])
} finally {
app.renderer.destroy()
}
})
test("selects the new final option immediately after removing the selected final option", async () => {
await using tmp = await tmpdir()
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
const select = await mountSelect(tmp.path, options)
try {
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "second")
select.app.mockInput.pressArrow("down")
await select.app.waitFor(() => select.moved.at(-1) === "third")
select.replaceOptions(options.slice(0, -1))
await select.app.waitForFrame((frame) => !frame.includes("third"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["second"])
} finally {
select.app.renderer.destroy()
}
})
test("selects a repopulated option after removing the only option", async () => {
await using tmp = await tmpdir()
const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }])
try {
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No results found"))
select.app.mockInput.pressEnter()
expect(select.selected).toEqual([])
select.replaceOptions([{ title: "replacement", value: "replacement" }])
await select.app.waitForFrame((frame) => frame.includes("replacement"))
select.app.mockInput.pressEnter()
await select.app.waitFor(() => select.selected.length === 1)
expect(select.selected).toEqual(["replacement"])
} finally {
select.app.renderer.destroy()
}
})
@@ -2,10 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatCompletedSubagentDetail,
formatSubagentRetry,
formatSubagentTitle,
formatSubagentToolcalls,
InlineToolRow,
parseApplyPatchFiles,
parseDiagnostics,
@@ -182,13 +180,6 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
})
test("formats completed subagent toolcall details", () => {
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
})
test("keeps background state attached to the subagent identity", () => {
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
@@ -207,5 +198,4 @@ describe("TUI inline tool wrapping", () => {
test("snapshots expanded tool errors under the tool text", async () => {
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
})
@@ -1,70 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal, onCleanup, type JSX } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { ThemeProvider } from "../../src/context/theme"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../src/keymap"
import { DialogProvider } from "../../src/ui/dialog"
import { DialogSelect } from "../../src/ui/dialog-select"
import { ToastProvider } from "../../src/ui/toast"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
async function mountDialogSelect(content: () => JSX.Element) {
const config = createTuiResolvedConfig()
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)
return (
<TestTuiContexts>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>{content()}</DialogProvider>
</ToastProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20 })
app.renderer.start()
return app
}
test("distinguishes loading, unfiltered empty, and filtered no-match states", async () => {
const [loading, setLoading] = createSignal(true)
const app = await mountDialogSelect(() => (
<DialogSelect title="Skills" options={[]} loading={loading()} emptyView={<text>Could not load skills</text>} />
))
try {
await app.waitForFrame((frame) => frame.includes("Loading..."))
setLoading(false)
await app.waitForFrame((frame) => frame.includes("Could not load skills"))
await app.mockInput.typeText("missing")
await app.waitForFrame((frame) => frame.includes("No matching results"))
expect(app.captureCharFrame()).not.toContain("Could not load skills")
} finally {
app.renderer.destroy()
}
})
test("uses a generic fallback for an unfiltered empty list", async () => {
const app = await mountDialogSelect(() => <DialogSelect title="Items" options={[]} />)
try {
await app.waitForFrame((frame) => frame.includes("No items"))
} finally {
app.renderer.destroy()
}
})
+1
View File
@@ -8,6 +8,7 @@
"dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"pretypecheck": "fumadocs-mdx",
"typecheck": "tsgo --noEmit"
},
"dependencies": {