mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 12:58:34 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11902f6fc9 | |||
| a873b3a2fb |
@@ -283,27 +283,21 @@ const AnthropicStreamDelta = Schema.Struct({
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const AnthropicEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
|
||||
content_block: Schema.optional(AnthropicStreamBlock),
|
||||
delta: Schema.optional(AnthropicStreamDelta),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const AnthropicEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
|
||||
content_block: Schema.optional(AnthropicStreamBlock),
|
||||
delta: Schema.optional(AnthropicStreamDelta),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
@@ -989,11 +983,7 @@ const onError = (event: AnthropicEvent) =>
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message: providerErrorMessage(event),
|
||||
code: event.error?.type,
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(event),
|
||||
}),
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
|
||||
@@ -155,75 +155,69 @@ const BedrockUsageSchema = Schema.Struct({
|
||||
})
|
||||
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
|
||||
|
||||
const BedrockStreamException = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
message: Schema.optional(Schema.String),
|
||||
originalMessage: Schema.optional(Schema.String),
|
||||
originalStatusCode: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const BedrockStreamException = Schema.Struct({
|
||||
message: Schema.optional(Schema.String),
|
||||
originalMessage: Schema.optional(Schema.String),
|
||||
originalStatusCode: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
// Streaming event shape — the AWS event stream wraps each JSON payload by its
|
||||
// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We
|
||||
// reconstruct that wrapping in `decodeFrames` below so the event schema can
|
||||
// stay a plain discriminated record.
|
||||
const BedrockEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
messageStart: Schema.optional(Schema.Struct({ role: Schema.String })),
|
||||
contentBlockStart: Schema.optional(
|
||||
Schema.Struct({
|
||||
contentBlockIndex: Schema.Number,
|
||||
start: Schema.optional(
|
||||
Schema.Struct({
|
||||
toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
contentBlockDelta: Schema.optional(
|
||||
Schema.Struct({
|
||||
contentBlockIndex: Schema.Number,
|
||||
delta: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
toolUse: Schema.optional(Schema.Struct({ input: Schema.String })),
|
||||
reasoningContent: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// Blob fields in Bedrock's JSON event stream are base64 strings.
|
||||
redactedContent: Schema.optional(Schema.String),
|
||||
// Vercel's Bedrock provider exposes the same delta under
|
||||
// Anthropic's shorter `data` spelling.
|
||||
data: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })),
|
||||
messageStop: Schema.optional(
|
||||
Schema.Struct({
|
||||
stopReason: Schema.String,
|
||||
additionalModelResponseFields: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(
|
||||
Schema.Struct({
|
||||
usage: Schema.optional(BedrockUsageSchema),
|
||||
metrics: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
internalServerException: Schema.optional(BedrockStreamException),
|
||||
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||
validationException: Schema.optional(BedrockStreamException),
|
||||
throttlingException: Schema.optional(BedrockStreamException),
|
||||
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const BedrockEvent = Schema.Struct({
|
||||
messageStart: Schema.optional(Schema.Struct({ role: Schema.String })),
|
||||
contentBlockStart: Schema.optional(
|
||||
Schema.Struct({
|
||||
contentBlockIndex: Schema.Number,
|
||||
start: Schema.optional(
|
||||
Schema.Struct({
|
||||
toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
contentBlockDelta: Schema.optional(
|
||||
Schema.Struct({
|
||||
contentBlockIndex: Schema.Number,
|
||||
delta: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
toolUse: Schema.optional(Schema.Struct({ input: Schema.String })),
|
||||
reasoningContent: Schema.optional(
|
||||
Schema.Struct({
|
||||
text: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// Blob fields in Bedrock's JSON event stream are base64 strings.
|
||||
redactedContent: Schema.optional(Schema.String),
|
||||
// Vercel's Bedrock provider exposes the same delta under
|
||||
// Anthropic's shorter `data` spelling.
|
||||
data: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })),
|
||||
messageStop: Schema.optional(
|
||||
Schema.Struct({
|
||||
stopReason: Schema.String,
|
||||
additionalModelResponseFields: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(
|
||||
Schema.Struct({
|
||||
usage: Schema.optional(BedrockUsageSchema),
|
||||
metrics: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
),
|
||||
internalServerException: Schema.optional(BedrockStreamException),
|
||||
modelStreamErrorException: Schema.optional(BedrockStreamException),
|
||||
validationException: Schema.optional(BedrockStreamException),
|
||||
throttlingException: Schema.optional(BedrockStreamException),
|
||||
serviceUnavailableException: Schema.optional(BedrockStreamException),
|
||||
})
|
||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
|
||||
// =============================================================================
|
||||
@@ -672,7 +666,6 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
reason: classifyProviderFailure({
|
||||
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(event),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -190,8 +190,19 @@ const GeminiCandidate = Schema.Struct({
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
@@ -200,6 +211,7 @@ interface ParserState {
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
@@ -503,32 +515,38 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized: promptBlockReason === undefined
|
||||
? mapFinishReason(finishReason, state.hasToolCalls)
|
||||
: "content-filter",
|
||||
raw: finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
|
||||
@@ -1012,12 +1012,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({
|
||||
message,
|
||||
code,
|
||||
status,
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(event),
|
||||
}),
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -209,22 +209,16 @@ const OpenAIChatChoice = Schema.Struct({
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
message: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenAIChatEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export const OpenAIChatEvent = Schema.Struct({
|
||||
choices: optionalNull(Schema.Array(OpenAIChatChoice)),
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
error: optionalNull(OpenAIChatError),
|
||||
})
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
|
||||
@@ -693,7 +687,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
message: event.error.message,
|
||||
code: event.error.code === undefined || event.error.code === null ? undefined : String(event.error.code),
|
||||
status: typeof event.error.code === "number" ? event.error.code : undefined,
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(event),
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
|
||||
@@ -77,7 +77,6 @@ const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly data: typeof Schema.Json.Type
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
@@ -94,12 +93,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const text = body || input.message
|
||||
const common = {
|
||||
message: input.message,
|
||||
data: input.data,
|
||||
providerMetadata: input.providerMetadata,
|
||||
http: input.http,
|
||||
}
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
if (
|
||||
|
||||
@@ -173,7 +173,6 @@ const statusError =
|
||||
reason: classifyProviderFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, body),
|
||||
data: body ?? null,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
@@ -194,7 +193,6 @@ const statusError =
|
||||
// request headers are empty.
|
||||
export const classifyHttpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly data: typeof Schema.Json.Type
|
||||
readonly url: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
@@ -207,7 +205,6 @@ export const classifyHttpFailure = (input: {
|
||||
const details = responseBody(input.responseBody)
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
data: input.data,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
|
||||
@@ -35,7 +35,6 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
|
||||
_tag: Schema.tag("InvalidRequest"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -56,7 +55,6 @@ export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
@@ -65,7 +63,6 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -75,7 +72,6 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.Rat
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
@@ -83,7 +79,6 @@ export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.E
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
@@ -91,7 +86,6 @@ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.E
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
status: Schema.optional(Schema.Number),
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -135,7 +129,6 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
data: Schema.optional(Schema.Json),
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
|
||||
@@ -216,7 +216,6 @@ export type Finish = Schema.Schema.Type<typeof Finish>
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
data: Schema.Json,
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||
|
||||
@@ -1031,24 +1031,12 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "error",
|
||||
error: { type: "overloaded_error", message: "Overloaded", request_id: "req_123" },
|
||||
}),
|
||||
),
|
||||
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
message: "overloaded_error: Overloaded",
|
||||
data: {
|
||||
type: "error",
|
||||
error: { type: "overloaded_error", message: "Overloaded", request_id: "req_123" },
|
||||
},
|
||||
})
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "overloaded_error: Overloaded" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -714,15 +714,11 @@ describe("Bedrock Converse route", () => {
|
||||
Effect.gen(function* () {
|
||||
const body = concat([
|
||||
eventFrame("messageStart", { role: "assistant" }),
|
||||
exceptionFrame("throttlingException", { message: "Slow down", requestId: "req_123" }),
|
||||
exceptionFrame("throttlingException", { message: "Slow down" }),
|
||||
])
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "RateLimit",
|
||||
message: "Slow down",
|
||||
data: { throttlingException: { message: "Slow down", requestId: "req_123" } },
|
||||
})
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -862,6 +862,51 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
|
||||
Effect.gen(function* () {
|
||||
const blocked = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ promptFeedback: { blockReason: "SAFETY" } },
|
||||
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(blocked.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
|
||||
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
|
||||
@@ -2592,25 +2592,13 @@ describe("OpenAI Responses route", () => {
|
||||
message: "Something went wrong",
|
||||
param: null,
|
||||
sequence_number: 1,
|
||||
diagnostic: { region: "us-east" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "UnknownProvider",
|
||||
message: "Something went wrong",
|
||||
data: {
|
||||
type: "error",
|
||||
code: null,
|
||||
message: "Something went wrong",
|
||||
param: null,
|
||||
sequence_number: 1,
|
||||
diagnostic: { region: "us-east" },
|
||||
},
|
||||
})
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -253,17 +253,14 @@ describe("OpenRouter", () => {
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
error: { code: 502, message: "Provider disconnected", upstream: "openai" },
|
||||
error: { code: 502, message: "Provider disconnected" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "ProviderInternal",
|
||||
data: { error: { code: 502, message: "Provider disconnected", upstream: "openai" } },
|
||||
})
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.message).toContain("Provider disconnected")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -478,12 +478,7 @@ export type Endpoint5_31Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly data?: Schema.Json | undefined
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -610,12 +605,7 @@ export type Endpoint5_31Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly data?: Schema.Json | undefined
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -777,12 +767,7 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly data?: Schema.Json | undefined
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -822,12 +807,7 @@ export type Endpoint5_31Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly data?: Schema.Json | undefined
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -868,12 +848,7 @@ export type Endpoint5_31Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly data?: Schema.Json | undefined
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number; data?: JsonValue }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
@@ -2647,12 +2647,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -2687,21 +2682,11 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -2732,12 +2717,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -2934,12 +2914,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -2974,21 +2949,11 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3019,12 +2984,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -3221,12 +3181,7 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -3261,21 +3216,11 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3306,12 +3251,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly data?: JsonValue
|
||||
}
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
}
|
||||
)
|
||||
>
|
||||
|
||||
@@ -780,10 +780,7 @@ function llmError(method: string, error: unknown) {
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({
|
||||
message: unknownErrorMessage(error),
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(jsonValue(error)),
|
||||
})
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
@@ -795,7 +792,6 @@ function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: details.message,
|
||||
data: Schema.decodeUnknownSync(Schema.Json)(jsonValue(error.data ?? error.responseBody ?? null)),
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
|
||||
@@ -29,41 +29,10 @@ V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
must still use V2 unless the user specifically requests a V1 result.
|
||||
|
||||
## [CLI](https://opencode.ai/v2/docs/cli)
|
||||
## [Configuration](https://opencode.ai/v2/docs/config)
|
||||
|
||||
For questions about the terminal interface, command-line invocation, `run`,
|
||||
`mini`, terminal providers, or other CLI behavior, fetch the
|
||||
[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from
|
||||
that section.
|
||||
|
||||
CLI and TUI preferences are separate from OpenCode's server and project
|
||||
configuration. They live in the global `~/.config/opencode/cli.json`, or
|
||||
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
|
||||
project-local CLI configuration. Most preferences can also be changed from the
|
||||
TUI by pressing `Ctrl+P` and selecting **Open settings**.
|
||||
|
||||
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
|
||||
before editing `cli.json`. It covers terminal-only settings such as themes,
|
||||
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
|
||||
and terminal integration. Do not put these settings in `opencode.json(c)`.
|
||||
|
||||
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
|
||||
|
||||
Configure keybindings under `keybinds` in `cli.json`. The leader key is the
|
||||
`keybinds.leader` entry; leader timing is configured separately under
|
||||
`leader.timeout`. Bindings can use a string, an array of strings, or an object
|
||||
when event behavior such as `preventDefault` is required. Disable a binding
|
||||
with `"none"` or `false`.
|
||||
|
||||
Never guess a command ID, default binding, or accepted key syntax. Fetch the
|
||||
full [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists
|
||||
the current IDs and defaults, before answering or editing a binding.
|
||||
|
||||
## [OpenCode configuration](https://opencode.ai/v2/docs/config)
|
||||
|
||||
OpenCode's server and project configuration uses JSON or JSONC. Include the
|
||||
published schema so the user's editor can validate fields and provide
|
||||
autocomplete:
|
||||
OpenCode configuration uses JSON or JSONC. Include the published schema so the
|
||||
user's editor can validate fields and provide autocomplete:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -86,10 +55,6 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
`references`, `formatter`, and `lsp`.
|
||||
|
||||
This configuration is distinct from `cli.json`. Use the
|
||||
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
|
||||
preferences, especially themes and keybindings.
|
||||
|
||||
Do not guess field names or shapes. Fetch the V2 configuration guide and its
|
||||
linked topic guide as the source of truth, and preserve unrelated settings when
|
||||
editing an existing file. Keep the published `$schema` URL in configuration
|
||||
|
||||
@@ -528,7 +528,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message, data: event.data })
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,11 +61,5 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
const data = "data" in reason ? reason.data : reason._tag === "InvalidProviderOutput" ? reason.raw : undefined
|
||||
return {
|
||||
type,
|
||||
message: reason.message,
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(data === undefined ? {} : { data }),
|
||||
}
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -487,7 +487,6 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
|
||||
expect(projected.type).toBe("provider.invalid-request")
|
||||
expect(projected.status).toBe(404)
|
||||
expect(projected.message).not.toBe("")
|
||||
expect(projected.data).toEqual({ error: { message: "", code: "not_found" } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -505,7 +504,6 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
expect(error.reason).toMatchObject({ data: '{"error":{"message":"","code":"not_found"}}' })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -61,24 +61,6 @@ describe("toSessionError", () => {
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "malformed", raw: "not-json" })))).toEqual({
|
||||
type: "provider.invalid-output",
|
||||
message: "malformed",
|
||||
data: "not-json",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider error data", () => {
|
||||
const data = {
|
||||
type: "error",
|
||||
sequence_number: 2,
|
||||
error: { type: "server_error", code: null, message: null },
|
||||
}
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "stream error", data })))).toEqual({
|
||||
type: "provider.unknown",
|
||||
message: "stream error",
|
||||
data,
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
|
||||
@@ -925,7 +925,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Second prompt")
|
||||
const titleFailed = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(
|
||||
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable", data: {} })).pipe(
|
||||
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
|
||||
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
|
||||
),
|
||||
TestLLM.text("Recovered", "text-recovered"),
|
||||
@@ -2179,7 +2179,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
|
||||
yield* runPrompt(session, "Earlier question")
|
||||
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable", data: {} })])
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
@@ -2337,7 +2337,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens", data: {} })],
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
TestLLM.text("Must not run", "text-after-failed-compaction"),
|
||||
)
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
@@ -2362,7 +2362,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
TestLLM.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
TestLLM.text("Recovered", "text-final"),
|
||||
@@ -2389,7 +2389,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
TestLLM.text("Recovered", "text-final-unknown-limit"),
|
||||
)
|
||||
@@ -2408,7 +2408,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
TestLLM.text("Recovered", "text-final-undersized-limit"),
|
||||
)
|
||||
@@ -2427,7 +2427,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const overflow = () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
yield* TestLLM.push(overflow(), TestLLM.text("## Objective\n- Recover once", "text-summary"), overflow())
|
||||
yield* admit(session, "Continue")
|
||||
@@ -2474,8 +2474,8 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable", data: {} })],
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
)
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
@@ -2502,7 +2502,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("## Objective\n- Interrupted", "text-summary"),
|
||||
)
|
||||
const first = yield* TestLLM.gate
|
||||
@@ -4078,10 +4078,9 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects provider errors as terminal assistant step failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const data = { type: "error", error: { type: "server_error" } }
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable", data }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
expect((yield* runPrompt(session, "Fail durably").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
@@ -4089,11 +4088,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Fail durably" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.unknown", message: "Provider unavailable", data },
|
||||
},
|
||||
{ type: "assistant", finish: "error", error: { type: "provider.unknown", message: "Provider unavailable" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -4101,7 +4096,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("projects provider errors emitted before assistant step start", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable", data: {} })])
|
||||
yield* TestLLM.push([LLMEvent.providerError({ message: "Provider unavailable" })])
|
||||
|
||||
expect((yield* runPrompt(session, "Fail before step").pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -4188,7 +4183,7 @@ describe("SessionRunnerLLM", () => {
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", data: {}, classification: "context-overflow" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
])
|
||||
expect((yield* runPrompt(session, "Fail after output").pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -4963,7 +4958,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
@@ -4990,7 +4985,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
expect((yield* runPrompt(session, "Fail hosted tool durably").pipe(Effect.flip)).message).toBe(
|
||||
@@ -5022,7 +5017,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
expect((yield* runPrompt(session, "Defect while provider fails").pipe(Effect.flip)).message).toBe(
|
||||
@@ -5049,7 +5044,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* TestLLM.push([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-store-provider-error", name: "storefail", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable", data: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
])
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.exit)).toMatchObject({
|
||||
|
||||
@@ -310,7 +310,7 @@ it.effect("retries after a failed title request", () =>
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Retry this title")
|
||||
const title = yield* SessionTitle.Service
|
||||
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable", data: {} }))
|
||||
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
|
||||
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
titleStream = successfulTitle
|
||||
|
||||
@@ -8,5 +8,4 @@ export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
data: Schema.Json.pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -12,11 +12,6 @@ describe("SessionError", () => {
|
||||
const values: SessionError.Error[] = [
|
||||
{ type: "provider.rate-limit", message: "Slow down" },
|
||||
{ type: "provider.auth", message: "Authentication failed" },
|
||||
{
|
||||
type: "provider.unknown",
|
||||
message: "Stream error",
|
||||
data: { type: "error", error: { type: "server_error" } },
|
||||
},
|
||||
{ type: "provider.future-condition", message: "A future provider failure" },
|
||||
{ type: "unknown", message: "Unexpected" },
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabNumberLabel,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
const numberWidth = () => 2
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
|
||||
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth() + 1}
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{sessionTabNumberLabel(index()).padStart(numberWidth())}
|
||||
{sessionTabShortcutLabel(index())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -1040,7 +1040,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
const numberWidth = () => Math.max(2, String(items().length).length)
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
|
||||
@@ -1140,8 +1141,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
|
||||
<text width={1} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -7,8 +7,10 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabNumberLabel(index: number) {
|
||||
return String(index + 1)
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabDetail(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabNumberLabel,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
@@ -25,8 +25,8 @@ describe("session tabs", () => {
|
||||
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels tabs by ordinal", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -36,9 +36,9 @@ describe("session tabs", () => {
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"10",
|
||||
"11",
|
||||
"12",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user