mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3340936d | |||
| f981861b67 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 5d87c7ad1f | |||
| fce506b3f9 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Vendored
+14
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } = {}) {},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user