Compare commits

...
Author SHA1 Message Date
Aiden Cline d6f28c8651 fix(core): continue retryable failures after durable output 2026-08-28 01:27:37 -05:00
Aiden Cline 11ca603490 fix(ai): finish chat streams at done sentinel (#45850) 2026-08-28 00:53:53 -05:00
Aiden Cline 85d8b07f09 fix(ai): ignore late converse tool deltas (#45847) 2026-08-28 00:38:01 -05:00
Aiden Cline d2ee536c16 fix(core): classify AISDK network failures as transport errors (#45840) 2026-08-28 00:21:17 -05:00
Aiden Cline e12e04f482 fix(core): strengthen background shell guidance (#45843) 2026-08-28 00:19:15 -05:00
Kit Langton 5743537945 test(core): use effectful temp fixtures (#45637) 2026-08-28 01:01:04 -04:00
Aiden Cline 18e22cd82e refactor(ai): simplify response stream state (#45835) 2026-08-27 23:56:42 -05:00
Aiden Cline bdf019a9ac fix(ai): detect DashScope input length overflow errors (#45834) 2026-08-27 23:43:14 -05:00
Aiden Cline 5cbafc57c0 refactor(ai): default unrecognized provider failures to retry (#45825) 2026-08-27 23:40:52 -05:00
Aiden Cline 55674b858b fix(ai): make final snapshots authoritative for text and reasoning (#45831) 2026-08-27 23:38:19 -05:00
Luke Parker bb390f435c fix(session-ui): enable word diffs in unified view (#45833) 2026-08-28 04:31:18 +00:00
Kit Langton 92b9eebab2 feat(tui): streamline diff review workflow (#45817) 2026-08-28 00:11:29 -04:00
Aiden Cline 074413a96d fix(ai): normalize response item boundaries (#45789) 2026-08-27 23:08:50 -05:00
opencode-agent[bot] 4685ba8d3e chore: update nix node_modules hashes 2026-08-28 04:03:16 +00:00
62 changed files with 3951 additions and 1312 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Fmwqp/fjTMX7gQW01Zgta51reLHp1S53GSQgBcJePrY=",
"aarch64-linux": "sha256-KLI6OIbvycMePKxt66nNKeILYJSEkufOIaSawZ/gotc=",
"aarch64-darwin": "sha256-go1wmrsHfYfJ3ukUIR/fveobyVba4E9G/rdsB/J2cQQ=",
"x86_64-darwin": "sha256-UnVYMijlG12k4RLFkTwf4Or0yXqFSwe1Ji6QCGufjzg="
"x86_64-linux": "sha256-EtUp4pHl9TyPtRrLGvk/X7kd2LuIxNxCpUwF5aLtzN4=",
"aarch64-linux": "sha256-m0j/pMZCguclR3/T9JmzCfi11YzmIvBFyR2bVhIO37Y=",
"aarch64-darwin": "sha256-nqefk68ZTUfNU15q1WkXaGsFzPNwOjCtMHpp6WrpNqM=",
"x86_64-darwin": "sha256-syD7hX62E4yCDV/wux1QKw4q/zZr24f99Y2mmzMJo6o="
}
}
@@ -496,6 +496,7 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
interface ParserState {
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
@@ -574,6 +575,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (event.contentBlockDelta?.delta?.toolUse) {
const index = event.contentBlockDelta.contentBlockIndex
if (state.finishedTools.has(index)) return [state, []] as const
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -612,6 +614,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
state.hasToolCalls,
lifecycle,
tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(
Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index)),
),
@@ -703,6 +706,7 @@ export const protocol = Protocol.make({
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
+158 -85
View File
@@ -391,17 +391,19 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly open: boolean
readonly encryptedContent: string | null | undefined
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
@@ -826,16 +828,16 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const phase = state.message.phase
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
if (state.messageItems.has(id)) {
if (state.message?.id === id) {
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
@@ -846,18 +848,62 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
lifecycle: Lifecycle.reasoningStart(
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "concluded" ? entry : [entry[0], "concluded" as const],
),
),
[index]: "active",
},
},
},
},
events,
]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item?.open) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.summaryParts[index] === "concluded") return [state, NO_EVENTS]
const [started, emitted] = startReasoningSummaryPart(state, itemID, index)
const current = started.reasoningItems[itemID]
if (!current) return [started, emitted]
const events: LLMEvent[] = [...emitted]
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
@@ -869,7 +915,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
if (!item?.open || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
@@ -878,32 +924,48 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs stream reasoning items in a stable order:
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
const phase = messagePhase(item.phase)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
events,
id,
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
)
}, state.lifecycle)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
lifecycle,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
NO_EVENTS,
events,
]
}
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
@@ -912,6 +974,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: true,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
@@ -923,6 +986,8 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -943,55 +1008,14 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
},
},
events,
]
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (!item?.open) return [state, NO_EVENTS]
if (item.summaryParts[event.summary_index] !== "active") return [state, NO_EVENTS]
return [
{
...state,
@@ -1047,11 +1071,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "message" && item.id !== undefined) {
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
@@ -1061,8 +1082,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
),
messageItems,
messagePhases,
message: state.message?.id === item.id ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1070,20 +1090,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const id = item.id ?? item.call_id
const tools = state.tools[id]
? state.tools
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1094,6 +1130,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
@@ -1104,20 +1141,49 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const metadata = reasoningMetadata(state, item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
@@ -1139,7 +1205,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
if (
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
@@ -1256,6 +1322,13 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
@@ -1305,10 +1378,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
message: undefined,
reasoningItems: {},
})
+8 -3
View File
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
import {
@@ -245,6 +246,8 @@ export const OpenAIChatEvent = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
const DONE = "[DONE]" as const
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)])
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface PendingToolDelta {
@@ -1166,7 +1169,7 @@ export const protocol = Protocol.make({
from: fromRequest,
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
event: OpenAIChatStreamEvent,
initial: (request) => ({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
@@ -1180,12 +1183,14 @@ export const protocol = Protocol.make({
nextToolIndex: 0,
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
}),
step,
step: (state: ParserState, event) => (event === DONE ? Effect.succeed([state, []] as const) : step(state, event)),
terminal: (event) => event === DONE,
onHalt: finishEvents,
},
})
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
export const framing = Framing.sseWithDone
export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>().with({ framing })
export const route = Route.make({
id: ADAPTER,
@@ -1,6 +1,5 @@
import { Route, type RouteRoutedLanguageModelInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import * as OpenAIChat from "./openai-chat.js"
const ADAPTER = "openai-compatible-chat"
@@ -19,7 +18,7 @@ export const route = Route.make({
providerMetadataKey: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
framing: OpenAIChat.framing,
})
export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
+5 -4
View File
@@ -207,15 +207,16 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
includeDone = false,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
@@ -240,7 +241,7 @@ export const sseFraming = (
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message")),
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
)
+15 -6
View File
@@ -62,22 +62,31 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string,
): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
events.push(LLMEvent.textEnd({ id, text, providerMetadata }))
const open = new Set(stepped.text)
open.delete(id)
return { ...stepped, text: open }
}
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
+23 -31
View File
@@ -37,6 +37,7 @@ const patterns = [
/too large for model with \d+ maximum context length/i,
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
/model_context_window_exceeded/i,
/range of input length should be/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
@@ -59,6 +60,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -74,7 +76,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
const NETWORK_ERROR_TEXT = /network[-_\s]error/i
export interface ProviderFailure {
readonly message: string
@@ -90,8 +91,12 @@ export interface ProviderFailure {
readonly rateLimit?: HttpRateLimitDetails | undefined
}
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
// Classification records affirmative evidence about a failure. Deterministic
// failures need positive identification (a 4xx status, quota/auth/policy
// signals); anything unrecognized stays UnknownProvider, which the session
// retry policy treats as retry-eligible because transient failures arrive in
// unpredictable shapes while deterministic rejections almost always carry a
// status or known code.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause }
const body = input.rawBody ?? ""
@@ -116,46 +121,33 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededError(details)
if (input.status === 401) return new AuthenticationError({ ...details, kind: "invalid" })
if (input.status === 403) return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (codes.includes("authentication_error")) return new AuthenticationError({ ...details, kind: "invalid" })
if (codes.includes("permission_error"))
return new AuthenticationError({ ...details, kind: "insufficient-permissions" })
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
if (
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception")
input.status === 429 ||
codes.some(
(code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception",
) ||
RATE_LIMIT_TEXT.test(text)
)
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
if (NETWORK_ERROR_TEXT.test(text)) return new ProviderInternalError(details)
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (input.status === 429) {
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
})
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
if (
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
)
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestError(details)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
return new InvalidRequestError(details)
// Any remaining 4xx is a deterministic rejection of this request.
if (input.status !== undefined && input.status >= 400 && input.status < 500) return new InvalidRequestError(details)
return new UnknownProviderError(details)
}
+1 -2
View File
@@ -5,7 +5,6 @@ import { ProviderShared } from "../protocols/shared.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
@@ -75,7 +74,7 @@ export const route = Route.make({
providerMetadataKey: "openai",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: Framing.sse,
framing: OpenAIChat.framing,
})
export const configure = (input: LanguageModelOptions = {}) => {
+1 -2
View File
@@ -1,7 +1,6 @@
import { Effect, Schema } from "effect"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
@@ -167,7 +166,7 @@ export const route = Route.make({
providerMetadataKey: "openrouter",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
framing: OpenAIChat.framing,
})
export const routes = [route]
+1 -1
View File
@@ -139,7 +139,7 @@ const toAIError = (error: AuthError): AIError => {
return new AIError({
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
? new AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
})
}
+8 -2
View File
@@ -8,8 +8,8 @@ import type { AIError } from "../schema/index.js"
* `Framing` is the byte-stream-shaped seam between transport and protocol:
*
* - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
* payload of one event.
* and emit the `data:` payload of each non-empty event. The default drops
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
* - AWS event stream — length-prefixed binary frames with CRC checksums.
* Each emitted frame is one parsed binary event record.
*
@@ -26,6 +26,12 @@ export interface Definition<Frame> {
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export const sseWithDone: Definition<string> = {
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
}
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
+1 -4
View File
@@ -44,10 +44,7 @@ export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoR
export class AuthenticationError extends Schema.TaggedError<AuthenticationError>("AI.Error.Authentication")(
"Authentication",
{
...ReasonFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
},
ReasonFields,
) {}
export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
+35 -14
View File
@@ -112,6 +112,8 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -134,6 +136,8 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -328,17 +332,32 @@ export const LLMEvent = Object.assign(llmEventTagged, {
})
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
for (const event of events) {
if (isDelta(event)) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, (parts.get(event.id) ?? "") + event.text)
}
if (isEnd(event) && event.text !== undefined) {
if (!parts.has(event.id)) order.push(event.id)
parts.set(event.id, event.text)
}
}
return order.map((id) => parts.get(id)).join("")
}
const responseText = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("")
joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd)
const responseReasoning = (events: ReadonlyArray<LLMEvent>) =>
events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("")
joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd)
const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
events.reduce<Usage | undefined>(
@@ -445,10 +464,11 @@ const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState
const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -478,10 +498,11 @@ const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): Resp
const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => {
const current = state.reasoningParts[event.id]
if (!current) return state
const text = event.text ?? current.text
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -579,12 +600,12 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
usage: Schema.optional(Usage),
finishReason: FinishReasonDetails,
}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
get text() {
return responseText(this.events)
}
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
get reasoning() {
return responseReasoning(this.events)
}
+9 -4
View File
@@ -11,6 +11,7 @@ describe("provider error classification", () => {
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Range of input length should be [1, 129024]",
"Too many tokens",
"Token limit exceeded",
]
@@ -87,10 +88,12 @@ describe("provider error classification", () => {
])
})
test("classifies network error text as provider internal", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
["network error", "network-error", "network_error"].map((message) => classifyProviderFailure({ message })._tag),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -103,10 +106,12 @@ describe("provider error classification", () => {
).toEqual(["QuotaExceeded", "ProviderInternal", "InvalidRequest"])
})
test("keeps unknown and malformed provider payloads non-retryable", () => {
test("leaves unrecognized failures unclassified for the retry default", () => {
expect(classifyProviderFailure({ message: '{"error":{"message":"no_kv_space"}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
})
})
@@ -491,6 +491,59 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores late tool deltas after contentBlockStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
[
"contentBlockStart",
{
contentBlockIndex: 0,
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
},
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"weather"}' } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"late":true}' } } }],
["messageStop", { stopReason: "tool_use" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.toolCalls).toEqual([
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
])
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
{
type: "tool-input-delta",
id: "tool_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
])
}),
)
it.effect("rejects tool deltas without contentBlockStart", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
Effect.flip,
)
expect(error).toMatchObject({
reason: { _tag: "InvalidProviderOutput" },
message: "Bedrock Converse tool delta is missing its tool call",
})
}),
)
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -52,7 +52,7 @@ describe("provider error retention", () => {
Effect.flip,
)
expect(error.message).toContain("Slow down")
expect(error.reason._tag).toBe(entry.name === "Gemini" ? "ProviderInternal" : "RateLimit")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
@@ -0,0 +1,467 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const completed = { type: "response.completed", response: { id: "resp_1" } }
const collect = (...input: OpenResponses.Event[]) =>
Effect.gen(function* () {
const events = yield* LLMClient.stream(request).pipe(
Stream.runCollect,
Effect.provide(fixedResponse(sseEvents(...input))),
)
expectLifecycle(
events,
input.some((event) => event.type === "response.completed"),
)
return events
})
// Deliberately local to these basic-item fixtures, not a general stream validator.
function expectLifecycle(events: ReadonlyArray<LLMEvent>, completed: boolean) {
const active = { text: new Set<string>(), reasoning: new Set<string>() }
const tools = new Map<string, "started" | "ended" | "called">()
events.forEach((event) => {
if (event.type === "text-start" || event.type === "reasoning-start") {
const blocks = event.type === "text-start" ? active.text : active.reasoning
expect(blocks.size).toBe(0)
blocks.add(event.id)
}
if (event.type === "text-delta" || event.type === "reasoning-delta") {
expect((event.type === "text-delta" ? active.text : active.reasoning).has(event.id)).toBe(true)
}
if (event.type === "text-end" || event.type === "reasoning-end") {
expect((event.type === "text-end" ? active.text : active.reasoning).delete(event.id)).toBe(true)
}
if (event.type === "tool-input-start") {
expect(tools.has(event.id)).toBe(false)
tools.set(event.id, "started")
}
if (event.type === "tool-input-delta") expect(tools.get(event.id)).toBe("started")
if (event.type === "tool-input-end") {
expect(tools.get(event.id)).toBe("started")
tools.set(event.id, "ended")
}
if (event.type === "tool-call") {
expect(tools.get(event.id)).toBe("ended")
tools.set(event.id, "called")
}
// Incomplete responses may leave pending tool inputs without a call.
if (event.type === "finish" && completed) {
expect(active.text.size).toBe(0)
expect(active.reasoning.size).toBe(0)
expect([...tools.values()].every((status) => status === "called")).toBe(true)
}
})
expect(events.filter(LLMEvent.is.stepStart)).toHaveLength(1)
expect(events[0]?.type).toBe("step-start")
expect(events.filter(LLMEvent.is.stepFinish)).toHaveLength(1)
expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
expect(events.slice(-2).map((event) => event.type)).toEqual(["step-finish", "finish"])
}
describe("Open Responses basic-item lifecycles", () => {
it.effect("closes implicit summary boundaries and ignores late events for completed reasoning", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const events = yield* collect(
{ type: "response.output_item.added", output_index: 0, item: { ...item, encrypted_content: null } },
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "Second" },
// The third part omits both explicit summary boundaries.
{
type: "response.reasoning_summary_text.delta",
output_index: 0,
item_id: "wrong",
summary_index: 2,
delta: "Third",
},
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 3 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 3, delta: "late" },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 2, text: "late final" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 3 },
completed,
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { "openai-compatible": { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third" },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
Effect.gen(function* () {
const item = {
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Not streamed" }],
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "late" },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 1, text: "late final" },
completed,
// Route termination must also prevent events after response completion.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_after" } },
)
expect(events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning-end",
id: "rs_1",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("forgets never-streamed messages at implicit boundaries and preserves refusal phases", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_empty" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.done", item_id: "msg_1", text: "Checking" },
{ type: "response.output_text.done", item_id: "msg_1", text: "Duplicate" },
{ type: "response.output_item.added", item: { type: "message", id: "msg_2", phase: null } },
{ type: "response.output_text.delta", item_id: "msg_empty", delta: "stale" },
{ type: "response.output_text.done", item_id: "msg_empty", text: "stale final" },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "late" },
{ type: "response.refusal.delta", item_id: "msg_2", delta: "Cannot help." },
{ type: "response.refusal.done", item_id: "msg_2", refusal: "Cannot help." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2", phase: "final_answer" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_3", phase: null } },
{ type: "response.refusal.done", item_id: "msg_3", refusal: "Done-only refusal." },
{ type: "response.output_item.done", item: { type: "message", id: "msg_3" } },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Checking" },
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{
type: "text-start",
id: "msg_2",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: null } },
},
{ type: "text-delta", id: "msg_2", text: "Cannot help." },
{
type: "text-end",
id: "msg_2",
providerMetadata: { "openai-compatible": { itemId: "msg_2", phase: "final_answer" } },
},
{
type: "text-start",
id: "msg_3",
providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } },
},
{ type: "text-delta", id: "msg_3", text: "Done-only refusal." },
{ type: "text-end", id: "msg_3", providerMetadata: { "openai-compatible": { itemId: "msg_3", phase: null } } },
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
Effect.gen(function* () {
const events = yield* collect(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"draft"}' },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
{ type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: '{"query":"final"}' },
{ type: "function_call", id: "fc_unseen", call_id: "call_unseen", name: "lookup", arguments: "{}" },
],
},
},
)
expect(events.slice(5, -2)).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
{
type: "tool-input-end",
id: "call_1",
name: "lookup",
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "final" },
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
},
])
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.output_item.added", item: { type: "message", id: "" } },
{ type: "response.output_item.added", item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_summary_text.delta", item_id: "", delta: "Thinking" },
{ type: "response.output_text.delta", item_id: "", delta: "Answer" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "", encrypted_content: "state" } },
{ type: "response.output_item.done", item: { type: "message", id: "" } },
completed,
)
expect(events.filter(LLMEvent.is.reasoningDelta).map((event) => event.text)).toEqual(["Thinking"])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Answer"])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([
{
type: "reasoning-end",
id: ":0",
providerMetadata: { "openai-compatible": { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("does not recover a completed tool from a tracked message with the same id", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "item_1" } },
{ type: "response.output_text.delta", item_id: "item_1", delta: "Answer" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "{}" }],
},
},
)
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.finish).map((event) => event.reason.normalized)).toEqual(["stop"])
}),
)
it.effect("flushes pending calls and open text when completed output is absent", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "final_answer" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
)
it.effect("does not reconcile pending calls or terminal reasoning metadata on incomplete responses", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Partial" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":' },
{
type: "response.incomplete",
response: {
id: "resp_1",
incomplete_details: { reason: "max_output_tokens" },
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "not-reconciled" },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"not-reconciled"}',
},
],
},
},
)
expect(events.filter(LLMEvent.is.toolInputEnd)).toEqual([])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(events.filter(LLMEvent.is.reasoningEnd)).toEqual([{ type: "reasoning-end", id: "rs_1:0" }])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "length", raw: "max_output_tokens" },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
})
@@ -807,6 +807,28 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("finishes at the done sentinel without waiting for response EOF", () =>
Effect.gen(function* () {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(sseEvents(deltaChunk({ content: "Hello" }), deltaChunk({}, "stop"))),
)
},
})
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(stream, {
headers: { "content-type": "text/event-stream" },
}),
),
)
expect(response.text).toBe("Hello")
expect(response.events.at(-1)?.type).toBe("finish")
}),
)
it.effect("preserves streamed refusals as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -405,6 +405,19 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("ignores events after the done sentinel", () =>
Effect.gen(function* () {
const body = `${sseEvents(
deltaChunk({ content: "Hello" }),
deltaChunk({}, "stop"),
)}data: ${JSON.stringify(deltaChunk({ content: " late" }))}\n\n`
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.text).toBe("Hello")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
}),
)
it.effect("accepts nullable usage and preserves provider fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2750,6 +2750,227 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("concludes reasoning at implicit summary boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
// The next part is enough to conclude the previous one even when
// its done event is delayed.
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
// Some compatible providers begin the next part with its first delta.
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 2, delta: "Third" },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecondThird")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First", providerMetadata: undefined },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning-start",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:2", text: "Third", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:2",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
it.effect("rejects a reasoning item that starts before the previous item ends", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_2" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("started reasoning before the previous item ended")
}),
)
it.effect("concludes text at implicit message boundaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// An item that never streams text is untracked at the boundary too.
{ type: "response.output_item.added", item: { type: "message", id: "msg_0" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
// The previous message's done event is missing; the next message
// item is the boundary for its open text.
{ type: "response.output_item.added", item: { type: "message", id: "msg_2" } },
// Late deltas for concluded or untracked messages must stay no-ops.
{ type: "response.output_text.delta", item_id: "msg_1", delta: " late" },
{ type: "response.output_text.delta", item_id: "msg_0", delta: " stale" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("FirstSecond")
expect(response.events.filter((event) => event.type.startsWith("text-"))).toMatchObject([
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
it.effect("opens the tool lifecycle for a done-only function call", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
// No output_item.added: the call arrives only as a completed item.
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type.startsWith("tool-"))).toMatchObject([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
// Duplicate added for a known item is not overlap and must no-op.
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Think" },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"weather"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Think")
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "tool-input-start")).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
])
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
+54
View File
@@ -104,6 +104,60 @@ describe("LLMResponse reducer", () => {
])
})
test("authoritative text-end value replaces accumulated deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("text-end without value keeps joined deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textDelta({ id: "t1", text: "Hel" }),
LLMEvent.textDelta({ id: "t1", text: "lo" }),
LLMEvent.textEnd({ id: "t1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response?.text).toBe("Hello")
})
test("authoritative reasoning-end value replaces only its own fragment", () => {
const response = LLMResponse.fromEvents([
LLMEvent.reasoningStart({ id: "r1:0" }),
LLMEvent.reasoningDelta({ id: "r1:0", text: "First summ" }),
LLMEvent.reasoningEnd({ id: "r1:0", text: "First summary." }),
LLMEvent.reasoningStart({ id: "r1:1" }),
LLMEvent.reasoningDelta({ id: "r1:1", text: "Second summary." }),
LLMEvent.reasoningEnd({ id: "r1:1" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{ type: "reasoning", text: "First summary." },
{ type: "reasoning", text: "Second summary." },
])
expect(response?.reasoning).toBe("First summary.Second summary.")
})
test("end value recovers a fragment that streamed no deltas", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "t1" }),
LLMEvent.textEnd({ id: "t1", text: "Hello!" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([{ type: "text", text: "Hello!" }])
expect(response?.text).toBe("Hello!")
})
test("clears malformed tool input without appending an executable call", () => {
const state = reduce([
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
+1 -1
View File
@@ -250,7 +250,7 @@ test("AI error reasons are tagged Errors with required messages", () => {
provider: model.provider,
model: model.id,
}),
new AuthenticationError({ message: "Missing credentials", kind: "missing" }),
new AuthenticationError({ message: "Missing credentials" }),
new RateLimitError({ message: "Rate limited" }),
new QuotaExceededError({ message: "Quota exceeded" }),
new ContentPolicyError({ message: "Content blocked" }),
+59 -4
View File
@@ -612,12 +612,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
Stream.unwrap(
Effect.tryPromise({
try: () => language.doStream(options),
catch: llmError,
catch: (error) => llmError(error, "request"),
}).pipe(
Effect.map((result) =>
Stream.fromReadableStream({
evaluate: () => result.stream,
onError: llmError,
onError: (error) => llmError(error, "read"),
}).pipe(
Stream.mapEffect((event) => streamPartEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
@@ -744,7 +744,7 @@ function streamPartEvents(
}),
])
case "error":
return Effect.fail(llmError(event.error))
return Effect.fail(llmError(event.error, "read"))
}
}
@@ -794,9 +794,20 @@ function messageValue(input: unknown) {
}
}
function llmError(error: unknown) {
function llmError(error: unknown, operation: "request" | "read") {
if (error instanceof AIError) return error
if (APICallError.isInstance(error)) return apiCallError(error)
const network = networkFailure(error)
if (network)
return new AIError({
reason: new TransportError({
message: network.message.trim() === "" ? unknownErrorMessage(error) : network.message,
cause: error,
transport: "http",
operation,
code: network.code,
}),
})
return new AIError({
reason: new UnknownProviderError({
message: unknownErrorMessage(error),
@@ -806,6 +817,50 @@ function llmError(error: unknown) {
})
}
// Runtime-generated network failure shapes. The codes mirror the AI SDK's own
// Bun network error list in handleFetchError; the messages are undici's fetch
// TypeError and stream termination strings plus our SSE chunk timeout error.
// Unrecognized shapes still retry via the UnknownProvider default; this match
// only adds transport semantics (continuation eligibility, display).
const NETWORK_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"EPIPE",
"ConnectionRefused",
"ConnectionClosed",
"FailedToOpenSocket",
])
const NETWORK_ERROR_MESSAGES = new Set([
"fetch failed",
"failed to fetch",
"terminated",
"other side closed",
"sse read timed out",
])
const NativeErrorShape = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeErrorShape = Schema.decodeUnknownOption(NativeErrorShape)
function networkFailure(error: unknown, depth = 0): { message: string; code?: string } | undefined {
if (depth > 4) return undefined
const shape = Option.getOrUndefined(decodeNativeErrorShape(error))
if (!shape) return undefined
// Prefer the deepest match: wrappers like undici's "fetch failed" TypeError
// carry the specific network code on their cause.
const cause = networkFailure(shape.cause, depth + 1)
if (cause) return cause
if (shape.code !== undefined && (NETWORK_ERROR_CODES.has(shape.code) || shape.code.startsWith("UND_ERR")))
return { message: shape.message, code: shape.code }
if (NETWORK_ERROR_MESSAGES.has(shape.message.trim().toLowerCase()))
return { message: shape.message, code: shape.code }
return undefined
}
function apiCallError(error: APICallError) {
const failure = RequestExecutor.httpFailure({
message: providerErrorMessage(error),
@@ -396,7 +396,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata))
yield* text.end(event.id, providerState(event.providerMetadata), event.text)
return
case "reasoning-start":
outputStarted = true
@@ -412,7 +412,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
return
case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata))
yield* reasoning.end(event.id, providerState(event.providerMetadata), event.text)
return
case "tool-input-start":
outputStarted = true
+5 -1
View File
@@ -26,12 +26,16 @@ export function isRetryable(error: AIError) {
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error.reason
+6 -1
View File
@@ -224,10 +224,15 @@ export const make = Effect.gen(function* () {
})
}
// After durable output, recovery continues instead of replaying: the
// partial assistant message is already persisted history. Any failure
// the pre-output gate would retry is continued here, plus interrupted
// streams, whose read failures may carry delivery states the retry
// policy rejects for full resends.
if (
llmFailure &&
llmError &&
isInterruptedStream(llmFailure) &&
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
record.outputStarted &&
tools.declines.length === 0 &&
!tools.interrupted
+1 -1
View File
@@ -19,7 +19,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
"You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response."
const OS =
process.platform === "darwin"
? "macOS"
+51 -2
View File
@@ -757,7 +757,7 @@ it.effect("classifies data-only AI SDK authentication errors", () =>
data: { error: { code: "authentication_error" } },
}),
)
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
}),
)
@@ -776,7 +776,7 @@ Object.entries({
responseBody,
})
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
expect(error.reason.body).toBe(responseBody)
expect(error.reason.cause).toBe(cause)
@@ -823,6 +823,55 @@ it.effect("retries status-less AI SDK transport failures", () =>
}),
)
it.effect("classifies native fetch failures as request transport errors", () =>
Effect.gen(function* () {
const cause = Object.assign(new TypeError("fetch failed"), {
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:443"), { code: "ECONNREFUSED" }),
})
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "ECONNREFUSED",
})
expect(error.message).toBe("connect ECONNREFUSED 127.0.0.1:443")
expect(error.reason.cause).toBe(cause)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("classifies mid-stream socket drops as read transport errors", () =>
Effect.gen(function* () {
const cause = Object.assign(new Error("terminated"), {
cause: Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" }),
})
const error = yield* streamFailure(cause, true)
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "read",
code: "UND_ERR_SOCKET",
})
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("classifies the SSE chunk timeout as a read transport error", () =>
Effect.gen(function* () {
const error = yield* streamFailure(new Error("SSE read timed out"), true)
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read" })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
}),
)
it.effect("keeps unrecognized error codes on the unknown provider path", () =>
Effect.gen(function* () {
const error = yield* streamFailure(Object.assign(new Error("kaput"), { code: "E_SOMETHING_ELSE" }), true)
expect(error.reason).toBeInstanceOf(UnknownProviderError)
}),
)
it.effect("prefers a structured provider message over the code fallback", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
+18 -7
View File
@@ -3,23 +3,34 @@ import os from "os"
import path from "path"
import { Effect } from "effect"
type TempDir = { readonly path: string }
export const tmpdir = async (prefix = "opencode-core-test-") => {
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
const dir = await make(prefix)
return {
path: dir,
async [Symbol.asyncDispose]() {
await remove(dir)
[Symbol.asyncDispose]() {
return remove(dir)
},
}
}
export const withTempDir = <A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) =>
export const tmpdirScoped = (prefix = "opencode-core-test-") =>
Effect.acquireRelease(
Effect.tryPromise(() => make(prefix)),
(dir) => Effect.tryPromise(() => remove(dir)).pipe(Effect.orDie),
).pipe(Effect.map((path) => ({ path })))
export const withTempDir = <A, E, R>(body: (tmp: TempDir) => Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
body,
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
Effect.tryPromise(() => make("opencode-core-test-")),
(path) => body({ path }),
(dir) => Effect.tryPromise(() => remove(dir)).pipe(Effect.orDie),
)
const make = async (prefix: string) => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
// Bun's callback APIs expose short paths and can hang during recursive removal on Windows.
async function remove(dir: string, retries = 30): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true })
+6 -8
View File
@@ -32,9 +32,7 @@ describe("toSessionError", () => {
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(llm(new AuthenticationError({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new AuthenticationError({ message: "auth" }))).type).toBe("provider.auth")
expect(toSessionError(llm(new QuotaExceededError({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyError({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(
@@ -129,14 +127,15 @@ describe("toSessionError", () => {
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
const eligible = [
llm(new RateLimitError({ message: "rate" })),
llm(new ProviderInternalError({ message: "internal" })),
llm(new TransportError({ message: "transport", transport: "http", operation: "request" })),
llm(new UnknownProviderError({ message: "unknown" })),
]
const ineligible = [
llm(new AuthenticationError({ message: "auth", kind: "invalid" })),
llm(new AuthenticationError({ message: "auth" })),
llm(new QuotaExceededError({ message: "quota" })),
llm(new ContentPolicyError({ message: "blocked" })),
llm(new InvalidProviderOutputError({ message: "output" })),
@@ -149,11 +148,10 @@ describe("toSessionError", () => {
model: ModelID.make("model"),
}),
),
llm(new UnknownProviderError({ 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([false, false, false, false, false, false])
})
test("retries transport failures only when delivery is absent or not sent", () => {
+4 -13
View File
@@ -17,7 +17,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -49,10 +49,7 @@ const itWithUnavailableDestination = testEffect(
describe("Session.move", () => {
itWithUnavailableDestination.effect("rejects an unavailable destination before admitting the move", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -72,10 +69,7 @@ describe("Session.move", () => {
)
it.effect("applies a move immediately when the source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -111,10 +105,7 @@ describe("Session.move", () => {
)
it.effect("keeps a moved session out of its former directory's new identity", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
tmpdirScoped().pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
+2 -5
View File
@@ -16,7 +16,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
const closed: Session.ID[] = []
const transport = Layer.succeed(
@@ -49,10 +49,7 @@ const it = testEffect(
describe("Session.remove", () => {
it.effect("removes a session and its children", () =>
Effect.gen(function* () {
const temporary = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const temporary = yield* tmpdirScoped()
const location = Location.Ref.make({ directory: AbsolutePath.make(temporary.path) })
const session = yield* Session.Service
const parent = yield* session.create({ location })
+2 -5
View File
@@ -23,7 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
@@ -42,10 +42,7 @@ describe("Session.revert files", () => {
"undoes and restores a file rename without losing either path",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const original = path.join(directory, "old name.txt")
const renamed = path.join(directory, "new name.txt")
@@ -287,6 +287,29 @@ it.effect("batches reasoning deltas and flushes pending reasoning before the ter
}),
)
test("authoritative end values replace accumulated deltas in the durable ended events", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "Hel" }),
LLMEvent.textEnd({ id: "text", text: "Hello!" }),
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "Thin" }),
LLMEvent.reasoningEnd({ id: "reasoning", text: "Thinking done." }),
],
publisher.publish,
{ discard: true },
),
)
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Hello!" })
expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({
text: "Thinking done.",
})
})
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
+78 -6
View File
@@ -11,6 +11,7 @@ import {
InvalidProviderOutputError,
InvalidRequestError,
RateLimitError,
UnknownProviderError,
} from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
@@ -848,18 +849,19 @@ function* verifyEphemeralDeltas(s: Scenario, kind: FragmentKind) {
function* verifyPartialFlushOnFailure(s: Scenario, kind: FragmentKind) {
const prompt = `Fail after ${kind}`
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
const failure = providerUnavailable()
// A non-retryable failure keeps the step terminal so the flushed fragments settle durably.
const failure = invalidRequest()
yield* s.admit(prompt)
yield* s.llm.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
expect(yield* s.resume.pipe(Effect.flip)).toBe(failure)
expect(yield* s.context).toMatchObject([
Expected.user(prompt),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
kind === "tool input"
? Expected.failedTool(
{ id: fragmentID(kind, "partial") },
{ error: { type: "provider.transport", message: "Provider unavailable" } },
{ error: { type: "provider.invalid-request", message: "Invalid request" } },
)
: fixture.expectedContent,
]),
@@ -3942,7 +3944,8 @@ describe("SessionRunnerLLM", () => {
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
yield* s.admit("Settle before failing")
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
const tools = yield* s.blockTools()
yield* s.llm.push(
TestLLM.failAfter(
@@ -4497,6 +4500,74 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("continues after a mid-stream rate limit honoring retry-after", function* (s) {
yield* s.admit("Continue after rate limit")
yield* s.llm.push(
TestLLM.failAfter(
rateLimited(5_000),
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "rate-limited-partial" }),
LLMEvent.textDelta({ id: "rate-limited-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("4999 millis")
expect(s.requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-2)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "Partial" }],
})
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
expect(yield* s.context).toMatchObject([
Expected.user("Continue after rate limit"),
Expected.assistant({ finish: "error", error: { type: "provider.rate-limit" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("continues after an unrecognized mid-stream provider failure", function* (s) {
const failure = new AIError({ reason: new UnknownProviderError({ message: "Provider returned error" }) })
yield* s.admit("Continue after unknown failure")
yield* s.llm.push(
TestLLM.failAfter(
failure,
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "unknown-failure-partial" }),
LLMEvent.textDelta({ id: "unknown-failure-partial", text: "Partial" }),
),
)
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
const run = yield* s.resume.pipe(Effect.forkChild)
yield* s.llm.wait(1)
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
})
expect(yield* s.context).toMatchObject([
Expected.user("Continue after unknown failure"),
Expected.assistant({ finish: "error", error: { type: "provider.unknown" } }, [Expected.text("Partial")]),
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
])
})
scenario("lowers interrupted reasoning before continuing an incomplete stream", function* (s) {
yield* s.admit("Continue interrupted reasoning")
yield* s.llm.push(
@@ -5254,7 +5325,8 @@ describe("SessionRunnerLLM", () => {
})
scenario("durably fails a hosted tool left unresolved by a raw provider stream failure", function* (s) {
const failure = providerUnavailable()
// Non-retryable so the step settles terminally instead of continuing after tool output.
const failure = invalidRequest()
yield* s.llm.push(
Stream.concat(
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
@@ -5278,7 +5350,7 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
expect(yield* s.context).toMatchObject([
Expected.user("Fail hosted tool on raw failure"),
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
Expected.failedTool({ id: "call-hosted-raw-failure" }, {}),
]),
])
+2 -5
View File
@@ -17,7 +17,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -182,10 +182,7 @@ describe("Session.view", () => {
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const tmp = yield* tmpdirScoped()
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
+2 -2
View File
@@ -1295,7 +1295,7 @@ describe("ShellTool", () => {
},
{
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
@@ -1535,7 +1535,7 @@ describe("ShellTool", () => {
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
text: "You will be notified automatically when the command finishes. The notification will include the command's output. DO NOT run sleep commands or poll the output file to check for completion. You can read from the file when its current output would be useful, such as when inspecting logs from a background server. Otherwise, continue with other work or end your response.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
@@ -0,0 +1,35 @@
import { expect, story } from "../../storybook/playwright/story"
for (const split of [false, true]) {
for (const theme of ["light", "dark"]) {
story(`highlights changed words in ${split ? "split" : "unified"} ${theme} diffs`, async ({ mount }) => {
const root = await mount("components-session-review--inline-changes", { args: { split }, globals: { theme } })
const diffs = root.locator("diffs-container")
await expect(diffs).toHaveCount(2)
for (const diff of await diffs.all()) {
await expect(diff.locator('[data-line] [style*="--syntax-"]')).not.toHaveCount(0)
}
const additions = root.locator('[data-line-type="change-addition"] [data-diff-span]')
await expect(additions).toHaveText(["select-text"])
await expect(root.locator('[data-line-type="context"] [data-diff-span]')).toHaveCount(0)
await expect(root.locator('[data-line-type="change-deletion"] [data-diff-span]')).toHaveText([
'"http" in',
"? error.reason.",
"response?.",
": undefined",
])
await expect(additions).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
})
}
for (const source of ["files", "metadata"]) {
story(`skips word diffs for large ${split ? "split" : "unified"} ${source}`, async ({ mount }) => {
const root = await mount("components-session-review--large-file", { args: { split, source } })
const line = root.locator('[data-line][data-line-type="change-addition"]')
await expect(line).toHaveText("export const value = 'after'")
// Plain first paint is not proof that the worker kept inline diffs disabled.
await expect(line.locator('[style*="--syntax-"]')).not.toHaveCount(0)
await expect(root.locator("[data-diff-span]")).toHaveCount(0)
})
}
}
@@ -4,7 +4,7 @@ import { expect, story } from "../../storybook/playwright/story"
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await review.locator('[data-line-type="change-addition"] [data-diff-span]').click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
+3 -2
View File
@@ -906,7 +906,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const workerPool = getWorkerPool()
const virtualizer = virtuals.get()
renderViewer({
@@ -1111,7 +1111,8 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
// Worker render options override per-viewer options, including the large-file fallback.
const workerPool = getWorkerPool(large() ? "none" : "word-alt")
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
@@ -1,6 +1,8 @@
import { createStore } from "solid-js/store"
import { parseDiffFromFile } from "@pierre/diffs"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { File } from "./file"
import { SessionReview, type SessionReviewComment } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
@@ -77,3 +79,79 @@ function InteractiveCommentsStory() {
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
const gitDiffs = [
{
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
file: "packages/session-ui/src/components/file.tsx",
additions: 1,
deletions: 1,
patch: `diff --git a/packages/session-ui/src/components/file.tsx b/packages/session-ui/src/components/file.tsx
index 704971b014..4876731cbd 100644
--- a/packages/session-ui/src/components/file.tsx
+++ b/packages/session-ui/src/components/file.tsx
@@ -702,7 +702,7 @@ function ViewerShell(props: {
data-mode={props.mode}
dir="ltr"
style={styleVariables}
- class="relative outline-none"
+ class="relative select-text outline-none"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
`,
},
{
// OpenCode 497a24c17d, unchanged git-show output.
file: "packages/core/src/session/runner/retry.ts",
additions: 1,
deletions: 1,
patch: `diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts
index 10f6680097..ef26792ffe 100644
--- a/packages/core/src/session/runner/retry.ts
+++ b/packages/core/src/session/runner/retry.ts
@@ -15,7 +15,7 @@ export interface Input {
}
\x20
export function isRetryable(error: AIError) {
- const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined
+ const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
`,
},
]
export const InlineChanges = {
args: { split: false },
render: (args: { split: boolean }) => (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[1100px] overflow-hidden bg-background-base">
<SessionReview
title="OpenCode Git history"
diffs={gitDiffs}
open={gitDiffs.map((diff) => diff.file)}
split={args.split}
/>
</div>
</CurrentSessionProviders>
),
}
export const LargeFile = {
args: { split: false, source: "files" },
argTypes: { source: { control: "select", options: ["files", "metadata"] } },
render: (args: { split: boolean; source: string }) => {
// Cross the viewer's 500,000-character limit without long changed lines or 1,000 total lines.
const padding = `// ${"unchanged ".repeat(70)}\n`.repeat(800)
const before = { name: "large.ts", contents: `export const value = 'before'\n${padding}` }
const after = { name: "large.ts", contents: `export const value = 'after'\n${padding}` }
const input = args.source === "metadata" ? { fileDiff: parseDiffFromFile(before, after) } : { before, after }
return (
<div class="h-screen overflow-auto bg-background-base">
<File mode="diff" {...input} diffStyle={args.split ? "split" : "unified"} />
</div>
)
},
}
+1 -1
View File
@@ -197,7 +197,7 @@ export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"])
disableBackground: false,
expansionLineCount: 20,
hunkSeparators: "line-info-basic",
lineDiffType: style === "split" ? "word-alt" : "none",
lineDiffType: "word-alt",
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
disableFileHeader: true,
+11 -12
View File
@@ -4,8 +4,6 @@ import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-regi
registerOpenCodeTheme()
export type WorkerPoolStyle = "unified" | "split"
export function workerFactory(): Worker {
return new Worker(ShikiWorkerUrl, { type: "module" })
}
@@ -32,24 +30,25 @@ function createPool(lineDiffType: "none" | "word-alt") {
return pool
}
let unified: WorkerPoolManager | undefined
let split: WorkerPoolManager | undefined
let plain: WorkerPoolManager | undefined
let diff: WorkerPoolManager | undefined
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): WorkerPoolManager | undefined {
if (typeof window === "undefined") return
if (style === "split") {
if (!split) split = createPool("word-alt")
return split
if (lineDiffType === "none") {
if (!plain) plain = createPool("none")
return plain
}
if (!unified) unified = createPool("none")
return unified
if (!diff) diff = createPool("word-alt")
return diff
}
export function getWorkerPools() {
const pool = getWorkerPool()
return {
unified: getWorkerPool("unified"),
split: getWorkerPool("split"),
unified: pool,
split: pool,
}
}
@@ -152,7 +152,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const locale = useLocale()
createEffect(() => {
getWorkerPool(props.diffStyle)
getWorkerPool()
})
const fileIndex = () => {
+11 -2
View File
@@ -1,4 +1,4 @@
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
For,
Index,
@@ -398,7 +398,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
}
return (
<Portal>
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Portal's wrapper otherwise follows the full-height app in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 2500
}}
>
<box
position="absolute"
left={0}
+13 -8
View File
@@ -66,21 +66,26 @@ export const Definitions = {
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.half_page.down": keybind("ctrl+d", "Scroll diff viewer down half a page"),
"diff.half_page.up": keybind("ctrl+u", "Scroll diff viewer up half a page"),
"diff.first": keybind("gg,home", "Go to the start of the diff"),
"diff.last": keybind("shift+g,end", "Go to the end of the diff"),
// Retain shipped configuration names without registering the removed tree navigation commands.
"diff.toggle": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.expand_all": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.collapse": keybind("none", "Deprecated: file tree is mouse-controlled"),
"diff.switch_focus": keybind("none", "Deprecated: keyboard navigation always controls the diff"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.next_file": keybind("n,alt+down", "Jump to next diff file"),
"diff.previous_file": keybind("p,alt+up", "Jump to previous diff file"),
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),
@@ -1,8 +1,3 @@
// Paths branch softly through the screen,
// A quiet tree of changed designs;
// Each leaf remembers what has been,
// And waits where careful light aligns.
export type FileTreeItem = {
readonly file: string
readonly status?: "added" | "deleted" | "modified"
@@ -119,44 +114,20 @@ export function compareFileTreeNodes(tree: FileTree, left: number, right: number
return left - right
}
export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
if (rows.length === 0) return undefined
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (index === -1) return rows[0]!.id
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
}
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (row?.kind !== "directory") return selected
const child = rows[index + 1]
return child && child.depth > row.depth ? child.id : selected
}
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (!row || row.depth === 0) return selected
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
return selected ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
@@ -186,19 +157,6 @@ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<nu
return next
}
export function setFileTreeDirectoryExpanded(
tree: FileTree,
expanded: ReadonlySet<number>,
selected: number | undefined,
value: boolean,
) {
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
const next = new Set(expanded)
if (value) next.add(selected)
else next.delete(selected)
return next
}
function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
const id = nodes.length
nodes.push({ ...input, id, children: [] })
@@ -1,156 +1,259 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { Locale } from "../../util/locale"
import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { truncateFilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { useTheme } from "../../context/theme"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_STATUS_WIDTH = 2
const FILE_TREE_STATUS_WIDTH = 1
export type DiffViewerFileTreeProps = {
readonly context: Plugin.Context
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly focused?: boolean
readonly highlightedNode?: number
readonly layout?: "tree" | "list"
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void
readonly source?: string
readonly onSwitchSource?: () => void
readonly footer?: JSX.Element
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const theme = props.context.theme
const theme = useTheme("elevated")
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
const rows = createMemo(() =>
list()
? flattenFileTree(tree()).filter((row) => row.fileIndex !== undefined)
: flattenFileTree(tree(), props.expandedNodes),
)
// Quieter than subdued text: markers are affordances, not content.
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
// Rails are pure texture; keep them barely above the surface.
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const node = props.highlightedNode
if (node === undefined) return
const selectedIndex = rows().findIndex((row) => row.id === node)
if (selectedIndex === -1) return
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
const index = rows().findIndex((row) => row.fileIndex !== undefined && row.fileIndex === props.selectedFileIndex)
if (index === -1) return
const top = index * (list() ? 3 : 1)
const height = list() ? 2 : 1
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height)
scrollSelectedIntoView()
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
backgroundColor={theme.background.default}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
<text
fg={
highlighted()
? theme.text.action.primary.focused
: selected()
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? theme.text.subdued
: theme.text.default
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
<text
id="diff-source-switch"
fg={
props.onSwitchSource
? sourceHovered()
? theme.text.action.secondary.hovered
: theme.text.action.secondary.default
: theme.text.default
}
attributes={TextAttributes.BOLD}
flexGrow={1}
wrapMode="none"
truncate
selectable={false}
onMouseOver={() => setSourceHovered(true)}
onMouseOut={() => setSourceHovered(false)}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onSwitchSource?.()
}}
>
{props.source ?? "Files"}
</text>
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
{reviewedCount()}/{props.files.length} reviewed
</text>
</box>
<scrollbox
id="diff-files"
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={theme.text.subdued}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<box flexShrink={0} gap={list() ? 1 : 0}>
<For each={rows()}>
{(row) => {
const [hovered, setHovered] = createSignal(false)
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const foreground = () => {
if (row.kind === "directory") return theme.text.subdued
return reviewed() ? theme.text.subdued : theme.text.default
}
const background = () => {
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const marker = () => {
if (row.kind !== "directory") return "≡ "
return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ "
}
// Rails run straight down from each ancestor folder; no end hooks.
const indent = createMemo(() => {
if (list()) return ""
return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
})
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const statusColor = () => {
if (reviewed()) return theme.text.subdued
const status = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.status
if (status === "added") return theme.diff.text.added
if (status === "deleted") return theme.diff.text.removed
return theme.text.subdued
}
const name = () => {
const width = contentWidth() - stringWidth(indent()) - stringWidth(marker())
if (row.kind === "directory") return truncateDirectoryChain(row.name, width)
return truncateFilePath(row.name, width)
}
const parent = () => {
const file = row.fileIndex === undefined ? "" : (props.files[row.fileIndex]?.file ?? "")
const directory = file.slice(0, Math.max(0, file.lastIndexOf("/")))
return directory ? truncateDirectoryChain(directory, contentWidth() - stringWidth(marker())) : ""
}
return (
<box
id={
row.fileIndex === undefined ? `diff-folder-row-${row.id}` : `diff-file-row-${row.fileIndex}`
}
wrapMode="none"
flexDirection="column"
width="100%"
height={list() ? 2 : 1}
flexShrink={0}
backgroundColor={background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
if (row.fileIndex !== undefined) props.onFileContextMenu?.(row.fileIndex, event)
}}
onMouseUp={(event) => {
if (event.button !== MouseButton.LEFT) return
event.stopPropagation()
props.onRowClick?.(row)
}}
>
{name()}
</text>
</box>
<text
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>
{status()}
</text>
</box>
)
}}
</For>
</Match>
</Switch>
</scrollbox>
</Panel>
<box flexDirection="row" height={1}>
<text wrapMode="none" flexShrink={0}>
<span style={{ fg: rail() }}>{indent()}</span>
<span style={{ fg: faint() }}>{marker()}</span>
</text>
<box flexGrow={1} minWidth={0} marginRight={1}>
<text
fg={foreground()}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
>
{name()}
</text>
</box>
<text fg={statusColor()} wrapMode="none" width={FILE_TREE_STATUS_WIDTH} flexShrink={0}>
{status()}
</text>
</box>
<Show when={list()}>
<text
fg={foreground()}
attributes={selected() || hovered() ? TextAttributes.DIM : undefined}
marginLeft={stringWidth(marker())}
wrapMode="none"
truncate
>
{parent()}
</text>
</Show>
</box>
)
}}
</For>
</box>
</Match>
</Switch>
</scrollbox>
<Show when={props.footer}>
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
{props.footer}
</box>
</Show>
</box>
</box>
)
}
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
if (!scroll) return
if (index < scroll.scrollTop) {
scroll.scrollTo(index)
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top: number, height: number) {
if (!scroll || scroll.isDestroyed) return
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (index >= scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(index - scroll.viewport.height + 1)
if (top + height > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + height - scroll.viewport.height)
}
}
function fileTreeRowPrefix(
rows: readonly FileTreeRow[],
index: number,
row: FileTreeRow,
expandedNodes: ReadonlySet<number> | undefined,
) {
const indentation = Array.from({ length: row.depth }, (_, depth) => {
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
return hasLaterSibling(rows, index, depth) ? "│ " : " "
}).join("")
const topRoot = index === 0 && row.depth === 0
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
return `${indentation}${branch}${marker}`
}
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
}
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
if (reviewed) return "✓"
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
}
// Collapsed chains drop whole leading segments instead of squeezing
// mid-segment, so "a/b/c/d" narrows to "…/c/d" rather than "…/b…/c/d".
function truncateDirectoryChain(name: string, maxWidth: number) {
if (stringWidth(name) <= maxWidth) return name
const kept: string[] = []
let width = stringWidth("…/")
for (const segment of name.split("/").toReversed()) {
const next = stringWidth(segment) + (kept.length ? 1 : 0)
if (width + next > maxWidth) break
kept.unshift(segment)
width += next
}
if (kept.length === 0) return truncateFilePath(name, maxWidth)
return `…/${kept.join("/")}`
}
@@ -0,0 +1,96 @@
/** @jsxImportSource @opentui/solid */
import { useTerminalDimensions } from "@opentui/solid"
import type { MouseEvent } from "@opentui/core"
import { createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { useTheme } from "../../context/theme"
import { useDialog } from "../../ui/dialog"
export function isDiffImageFile(file: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(file)
}
export function DiffViewerImage(props: {
file: string
load: (file: string, signal: AbortSignal) => Promise<Uint8Array>
label?: string
}) {
const theme = useTheme()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const height = () => Math.max(3, Math.min(8, Math.floor(dimensions().height / 4)))
const [image] = createResource(
() => {
const controller = new AbortController()
onCleanup(() => controller.abort())
return { file: props.file, signal: controller.signal }
},
(input) => props.load(input.file, input.signal),
)
return (
<box width="100%" flexShrink={0} gap={1} paddingLeft={1} paddingRight={1} paddingBottom={1}>
<text fg={theme.text.subdued}>{props.label ?? "Working tree preview"}</text>
<box height={height() + 2} flexShrink={0} gap={1}>
<Switch>
<Match when={image.error}>
<text fg={theme.text.feedback.error.default}>Could not load image</text>
</Match>
<Match when={image.loading}>
<text fg={theme.text.subdued}>Loading image...</text>
</Match>
<Match when={!image.error && image()} keyed>
{(bytes) => {
const [failed, setFailed] = createSignal(false)
const [size, setSize] = createSignal<string>()
const open = (event: MouseEvent) => {
if (event.button !== 0 || !size() || failed()) return
event.stopPropagation()
dialog.replace(() => (
<DialogImagePreview
images={[
{
uri: `data:application/octet-stream;base64,${Buffer.from(bytes).toString("base64")}`,
mention: { text: props.file },
},
]}
initial={0}
/>
))
}
return (
<Show
when={!failed()}
fallback={<text fg={theme.text.feedback.error.default}>Could not decode image</text>}
>
<box width="100%" height={height()} onMouseUp={open}>
<image
id={`diff-image-${props.file}`}
source={bytes}
fit="fit"
protocol="auto"
width="100%"
height="100%"
onLoad={(loaded) => setSize(`${loaded.width} x ${loaded.height}`)}
onError={() => setFailed(true)}
/>
</box>
<Show when={size()}>
{(value) => (
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}>{value()}</text>
<text fg={theme.text.action.secondary.default} onMouseUp={open}>
Click to enlarge
</text>
</box>
)}
</Show>
</Show>
)
}}
</Match>
</Switch>
</box>
</box>
)
}
@@ -1,108 +0,0 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
}
function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
</PanelGroupContext.Provider>
)
}
export function Panel(
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
) {
const group = usePanelGroup()
const [local, boxProps] = splitProps(props, ["border", "context"])
const context = local.context ?? group?.context
if (!context) throw new Error("Panel context is missing")
const theme = context.theme
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: theme.border.default,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
)
}
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
}
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
if (!group) throw new Error("PanelGroup is missing")
const theme = group.context.theme
const color = () => props.color ?? theme.border.default
const axis = () => props.axis ?? crossAxis(group.axis)
if (axis() === "y") {
return (
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
return (
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "├" : "┤"
if (edge === "edge-in") return "┴"
return "┬"
}
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "┬" : "┴"
if (edge === "edge-in") return "┤"
return "├"
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -236,7 +236,10 @@ function settle<T>(resolve: (value: T) => void) {
}
}
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
export function createDialogApi(
dialog: ReturnType<typeof useDialog>,
provide: (render: () => JSX.Element) => JSX.Element,
) {
const api: Dialog = {
show(render, onClose) {
dialog.replace(() => provide(render), onClose)
@@ -2,16 +2,11 @@
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { emptyThemeSource } from "../../fixture/fixture"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import {
DiffViewerFileTree,
type DiffViewerFileTreeProps,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
import { TestTuiContexts } from "../../fixture/tui-environment"
import {
allExpandedFileTreeDirectories,
@@ -19,44 +14,44 @@ import {
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const lines = visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
focused={true}
/>
)),
)
test("defaults to text-line file icons and triangle folders with straight rails", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
/>
))
expect(lines).toEqual([
expect(visibleLines(frame)).toEqual([
"Files 0/5 reviewed",
"▾ a",
"│ ├─ alpha.ts ?",
"│ └─ zeta.ts ?",
"├─ ▾ b",
"│ ├─ alpha.ts ?",
"│ └─ file.ts ?",
"│ alpha.ts ?",
"│ zeta.ts ?",
"▾ b",
"│ alpha.ts ?",
"│ file.ts ?",
"≡ z-file.ts ?",
])
expect(frame).not.toMatch(/[├└─]/)
})
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff…")
@@ -64,32 +59,64 @@ describe("DiffViewerFileTree", () => {
expect(failed).not.toContain("Failed to load diff")
expect(failed).not.toContain("No files")
expect(empty).toContain("No files")
expect(
empty
.split("\n")
.find((line) => line.includes("No files"))
?.indexOf("No files"),
).toBe(2)
})
test("does not render text markers for highlighted rows", async () => {
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
test.each(["tree", "list"] as const)("%s layout uses two-cell horizontal sidebar padding", async (layout) => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
layout={layout}
files={[
{ file: "src/a.ts", status: "added" },
{ file: "README.md", status: "modified" },
]}
loading={false}
error={undefined}
/>
))
const lines = frame
.split("\n")
.slice(1)
.filter((line) => line.trim())
expect(lines.find((line) => line.includes("Files"))?.indexOf("Files")).toBe(2)
const file = lines.find((line) => line.includes("README.md"))!
expect(file.indexOf("≡")).toBe(2)
expect(file.slice(29, 32)).toBe("M ")
expect(lines.every((line) => line.startsWith(" ") && line.slice(30, 32) === " ")).toBe(true)
})
const focused = visibleLines(
test.each(["dark", "light"] as const)("full top padding keeps the heading one row down in %s mode", async (mode) => {
const frame = await renderFrame(
() => <DiffViewerFileTree width={32} files={[{ file: "README.md" }]} loading={false} error={undefined} />,
mode,
)
const lines = frame.split("\n")
expect(lines[0].trim()).toBe("")
expect(lines[1].indexOf("Files")).toBe(2)
expect(lines.find((line) => line.includes("README.md"))?.indexOf("≡")).toBe(2)
})
test("does not render text markers for selected files", async () => {
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
const selected = visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
focused
highlightedNode={src.id}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} selectedFileIndex={0} />
)),
)
const unfocused = visibleLines(
await renderFrame(() => <ThemedDiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
const unselected = visibleLines(
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
expect(unfocused).toContain("▾ src/config")
expect(focused.some((line) => line.includes("*"))).toBe(false)
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
expect(selected).toContain("▾ src/config")
expect(unselected).toContain("▾ src/config")
expect(selected.some((line) => line.includes("*"))).toBe(false)
expect(unselected.some((line) => line.includes("*"))).toBe(false)
})
test("renders collapsed and expanded directory rows", async () => {
@@ -102,21 +129,15 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
expandedNodes={collapsed}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src/config"])
).toEqual(["Files 0/2 reviewed", "▸ src/config", "≡ README.md ?"])
expect(
visibleLines(
await renderFrame(() => (
<ThemedDiffViewerFileTree
<DiffViewerFileTree
files={files}
width={32}
loading={false}
@@ -125,49 +146,111 @@ describe("DiffViewerFileTree", () => {
/>
)),
),
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
).toEqual(["Files 0/2 reviewed", "▾ src/config", "│ tui.ts ?", "≡ README.md ?"])
})
test.each(["dark", "light"] as const)(
"file tabs distinguish duplicate basenames and review state in %s",
async (mode) => {
const frame = await renderFrame(
() => (
<DiffViewerFileTree
width={32}
layout="list"
files={[
{ file: "src/sidebar.tsx", status: "added" },
{ file: "test/sidebar.tsx", status: "modified" },
]}
loading={false}
error={undefined}
selectedFileIndex={0}
reviewedFileNames={new Set(["src/sidebar.tsx"])}
/>
),
mode,
)
expect(visibleLines(frame)).toEqual(["Files 1/2 reviewed", "≡ sidebar.tsx ✓", "src", "≡ sidebar.tsx M", "test"])
expect(frame).not.toMatch(/[│├└─]/)
},
)
test("keeps rows quiet: straight rails, single status letters, no hooks or dots", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={[
{ file: "src/a.ts", status: "added" },
{ file: "src/b.ts", status: "modified" },
{ file: "test/a.ts", status: "deleted" },
]}
loading={false}
error={undefined}
/>
))
const lines = visibleLines(frame)
expect(lines).toEqual(["Files 0/3 reviewed", "▾ src", "│ ≡ a.ts A", "│ ≡ b.ts M", "▾ test", "│ ≡ a.ts D"])
expect(frame).not.toMatch(/[├└─·]/)
expect(frame).not.toMatch(/[\uE000-\uF8FF]/)
})
test("file tabs align parent paths beneath marked filenames", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={32}
layout="list"
files={[{ file: "src/sidebar.tsx", status: "modified" }]}
loading={false}
error={undefined}
/>
))
expect(visibleLines(frame)).toEqual(["Files 0/1 reviewed", "≡ sidebar.tsx M", "src"])
const lines = frame.split("\n")
expect(lines.find((line) => line.includes("src"))?.indexOf("src")).toBe(
lines.find((line) => line.includes("sidebar.tsx"))?.indexOf("sidebar.tsx"),
)
})
test("narrow collapsed chains drop whole leading segments", async () => {
const frame = await renderFrame(() => (
<DiffViewerFileTree
width={26}
files={[
{ file: "packages/tui/src/feature-plugins/system/deeply/nested/selection.ts" },
{ file: "packages/tui/src/feature-plugins/system/other/index.ts" },
]}
loading={false}
error={undefined}
/>
))
const lines = visibleLines(frame)
expect(lines).toContain("▾ …/system")
expect(frame).not.toMatch(/\S+…\//)
})
})
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
}
async function renderFrame(component: () => JSX.Element) {
const mounted = Promise.withResolvers<void>()
const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 })
async function renderFrame(component: () => JSX.Element, mode: "dark" | "light" = "dark") {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode={mode} source={emptyThemeSource}>
{component()}
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 40, height: 20 },
)
try {
await mounted.promise
await app.renderOnce()
await app.renderOnce()
return app.captureCharFrame()
return await app.waitForFrame((frame) => frame.includes("Files"))
} finally {
app.renderer.destroy()
}
}
function withTheme(component: () => JSX.Element, onReady = () => {}) {
return (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Ready onReady={onReady}>{component()}</Ready>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
)
}
function Ready(props: ParentProps<{ onReady: () => void }>) {
onMount(props.onReady)
return props.children
}
function visibleLines(frame: string) {
return frame
.split("\n")
.map((line) => line.trimEnd())
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
.map((line) => line.trim().replace(/\s+/g, " "))
.filter(Boolean)
}
@@ -0,0 +1,172 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { ImageRenderable, type Renderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal, Show, type JSX } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ThemeProvider } from "../../../src/context/theme"
import { Keymap } from "../../../src/context/keymap"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { DiffViewerImage, isDiffImageFile } from "../../../src/feature-plugins/system/diff-viewer-image"
import { diffImageFixture } from "../../fixture/diff-image"
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
test("recognizes only supported image file extensions", () => {
for (const extension of ["png", "jpg", "jpeg", "webp", "gif", "PNG", "JPEG"]) {
expect(isDiffImageFile(`assets/preview.${extension}`)).toBe(true)
}
for (const file of ["assets/preview.svg", "preview.avif", "image.png.ts", "png", "image.png/file"]) {
expect(isDiffImageFile(file)).toBe(false)
}
})
test.each([
{ width: 40, height: 16, mode: "dark" as const },
{ width: 120, height: 40, mode: "light" as const },
])("renders a real image at $width columns in $mode mode", async (options) => {
const pending = Promise.withResolvers<Uint8Array>()
const requested: string[] = []
const app = await renderImage(
() => (
<DiffViewerImage
file="assets/landscape.png"
load={(file) => {
requested.push(file)
return pending.promise
}}
/>
),
options,
)
try {
await app.waitForFrame((frame) => frame.includes("Loading image..."))
pending.resolve(diffImageFixture)
await app.waitForFrame((frame) => frame.includes("96 x 48"))
expect(requested).toEqual(["assets/landscape.png"])
expect(app.captureCharFrame()).toContain("Working tree preview")
const image = findImage(app.renderer.root)!
expect(image.image?.width).toBe(96)
expect(image.image?.height).toBe(48)
expect(image.width).toBe(options.width - 2)
expect(image.height).toBe(Math.min(8, options.height / 4))
expect(image.fit).toBe("fit")
expect(image.protocol).toBe("auto")
} finally {
app.renderer.destroy()
}
})
test("clicking a thumbnail opens the shared image modal and escape returns to the preview", async () => {
const app = await renderImage(() => <DiffViewerImage file="landscape.png" load={async () => diffImageFixture} />)
try {
await app.waitForFrame((frame) => frame.includes("Click to enlarge"))
const thumbnail = findImage(app.renderer.root)!
await app.mockMouse.click(thumbnail.x + Math.floor(thumbnail.width / 2), thumbnail.y + 1)
await app.waitForFrame((frame) => frame.includes("Image 1 of 1"))
const modal = app.renderer.root.findDescendantById("prompt-image-viewer-image")
expect(modal).toBeInstanceOf(ImageRenderable)
if (!(modal instanceof ImageRenderable)) throw new Error("Missing image modal")
await app.waitFor(() => modal.image?.width === 96)
expect(modal.image?.height).toBe(48)
expect(modal.height).toBeGreaterThan(thumbnail.height)
expect(app.captureCharFrame()).toContain("landscape.png")
app.mockInput.pressEscape()
await app.waitForFrame((frame) => !frame.includes("Image 1 of 1"))
expect(findImage(app.renderer.root)).toBe(thumbnail)
expect(app.captureCharFrame()).toContain("Click to enlarge")
} finally {
app.renderer.destroy()
}
})
test("pending image reads are aborted when the source changes or the preview closes", async () => {
const pending = Promise.withResolvers<Uint8Array>()
const [file, setFile] = createSignal("a.png")
const [visible, setVisible] = createSignal(true)
const signals: AbortSignal[] = []
const app = await renderImage(() => (
<Show when={visible()}>
<DiffViewerImage
file={file()}
load={(_, signal) => {
signals.push(signal)
return pending.promise
}}
/>
</Show>
))
try {
await app.waitForFrame((frame) => frame.includes("Loading image..."))
expect(signals[0].aborted).toBe(false)
setFile("b.png")
await app.flush()
expect(signals).toHaveLength(2)
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
setVisible(false)
await app.flush()
expect(signals[1].aborted).toBe(true)
} finally {
app.renderer.destroy()
pending.resolve(diffImageFixture)
}
})
test.each(["fetch", "decode"])("recovers from a %s error when the file changes", async (failure) => {
const [file, setFile] = createSignal("broken.png")
const app = await renderImage(() => (
<DiffViewerImage
file={file()}
label="Story fixture preview"
load={async (file) => {
if (file !== "broken.png") return diffImageFixture
if (failure === "fetch") throw new Error("Unavailable")
return new Uint8Array([1, 2, 3])
}}
/>
))
try {
await app.waitForFrame((frame) =>
frame.includes(failure === "fetch" ? "Could not load image" : "Could not decode image"),
)
expect(findImage(app.renderer.root)).toBeUndefined()
setFile("landscape.png")
await app.waitForFrame((frame) => frame.includes("96 x 48"))
expect(app.captureCharFrame()).toContain("Story fixture preview")
expect(app.captureCharFrame()).not.toContain("Could not")
expect(findImage(app.renderer.root)?.image?.width).toBe(96)
} finally {
app.renderer.destroy()
}
})
function renderImage(
component: () => JSX.Element,
options = { width: 80, height: 24, mode: "dark" as "dark" | "light" },
) {
return testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode={options.mode} source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>{component()}</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ ...options, kittyKeyboard: true },
)
}
function findImage(root: Renderable): ImageRenderable | undefined {
if (root instanceof ImageRenderable) return root
return root.getChildren().map(findImage).find(Boolean)
}
File diff suppressed because it is too large Load Diff
@@ -65,7 +65,7 @@ test("releasing a transcript selection over tab controls does not activate them"
}
})
test("the tab context menu keeps preview tabs open without offering promotion for permanent tabs", async () => {
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const controller = {
@@ -104,18 +104,31 @@ test("the tab context menu keeps preview tabs open without offering promotion fo
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.click(5, 0, MouseButton.RIGHT)
const first = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
const second = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Second"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
expect(app.captureCharFrame()).toContain("Rename")
expect(app.captureCharFrame()).toContain("Close")
expect(active()).toBe("first")
const frame = app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
await app.waitForFrame((frame) => !frame.includes("Rename"))
expect(promoted).toEqual(["second"])
expect(active()).toBe("first")
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { batch, createSignal } from "solid-js"
import { ConfigProvider, useConfig, type Info } from "../../src/config"
@@ -13,23 +14,27 @@ import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider } from "../../src/context/session-tabs"
import { StorageProvider } from "../../src/context/storage"
import { ThemeProvider, useTheme } from "../../src/context/theme"
import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const orientation of ["horizontal", "vertical"] as const) {
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
test(`${orientation} tabs replace ordinals with status without moving titles and keep context menu actions`, async () => {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const [preview, setPreview] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
@@ -53,6 +58,10 @@ for (const orientation of ["horizontal", "vertical"] as const) {
},
close() {},
move() {},
isPreview: (sessionID: string) => sessionID === "first" && preview(),
promote(sessionID: string) {
if (sessionID === "first") setPreview(false)
},
detail: () => "project",
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
@@ -78,7 +87,19 @@ for (const orientation of ["horizontal", "vertical"] as const) {
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Colors />
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
<Keymap.Provider>
<ToastProvider>
<DialogProvider>
<box width="100%" height="100%">
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
</box>
</DialogProvider>
</ToastProvider>
</Keymap.Provider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
@@ -227,6 +248,46 @@ for (const orientation of ["horizontal", "vertical"] as const) {
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
setAnimations(false)
setStatus(EMPTY_SESSION_TAB_STATUS)
setActive("second")
await app.renderOnce()
const rows = app.captureCharFrame().split("\n")
const row = rows.findIndex((line) => line.includes("First"))
const column = rows[row]!.indexOf("First")
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1)
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
expect(active()).toBe("second")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
setPreview(true)
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
expect(app.captureCharFrame()).toContain("Rename")
expect(app.captureCharFrame()).toContain("Close")
expect(active()).toBe("second")
for (const size of [
{ width: 18, height: 4, row: 1, column: 3 },
{ width: 60, height: 10, row: row + 1, column: column + 1 },
]) {
app.resize(size.width, size.height)
await app.waitForFrame((frame) => frame.split("\n")[0]?.length === size.width && frame.includes("Keep open"))
const rows = app.captureCharFrame().split("\n")
expect(rows[size.row]?.indexOf("Keep open")).toBe(size.column)
expect(rows[size.row + 1]?.indexOf("Rename")).toBe(size.column)
expect(rows[size.row + 2]?.indexOf("Close")).toBe(size.column)
}
const menu = app.captureCharFrame().split("\n")
const keepOpen = menu.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(menu[keepOpen]!.indexOf("Keep open"), keepOpen)
await app.waitForFrame((frame) => !frame.includes("Rename"))
expect(preview()).toBe(false)
expect(active()).toBe("second")
setNewTab(true)
await app.waitForFrame((frame) => frame.includes("+ New session"))
} finally {
+21
View File
@@ -179,6 +179,10 @@ test("accepts every v2-only named command ID", () => {
"diff.up",
"diff.page.down",
"diff.page.up",
"diff.half_page.down",
"diff.half_page.up",
"diff.first",
"diff.last",
"diff.mark_reviewed",
"opencode.settings",
"service.restart",
@@ -207,7 +211,16 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.up": "k,up",
"diff.page.down": "pagedown,ctrl+f",
"diff.page.up": "pageup,ctrl+b",
"diff.half_page.down": "ctrl+d",
"diff.half_page.up": "ctrl+u",
"diff.first": "gg,home",
"diff.last": "shift+g,end",
"diff.next_file": "n,alt+down",
"diff.previous_file": "p,alt+up",
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
@@ -219,6 +232,14 @@ test("centralizes named command defaults and resolves explicit none", () => {
Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
})
test("retired diff tree keybinds remain accepted but have no default bindings", () => {
const ids = ["diff.toggle", "diff.expand", "diff.expand_all", "diff.collapse", "diff.switch_focus"]
const defaults = resolve({}, { terminalSuspend: true })
const overrides = Object.fromEntries(ids.map((id) => [id, "ctrl+alt+x"]))
expect(decodeInfo({ keybinds: overrides }).keybinds).toEqual(overrides)
ids.forEach((id) => expect(defaults.keybinds.get(id)).toEqual([]))
})
test("rejects orphaned keybind definitions", () => {
expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
})
@@ -4,12 +4,8 @@ import {
buildFileTree,
fileTreeFileSelection,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
setFileTreeDirectoryExpanded,
showDiffViewerFileTree,
singlePatchFileIndex,
toggleFileTreeDirectory,
@@ -173,67 +169,18 @@ describe("diff viewer file tree utilities", () => {
])
})
test("moves selection across visible rows and clamps to bounds", () => {
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
})
test("moves directory selection to first visible child", () => {
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const tui = rows.find((row) => row.name === "tui.ts")!
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
})
test("moves collapsed chain selection to first visible child", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
)
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
})
test("moves file and collapsed directory selection to visible parent", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
)
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
const app = rows.find((row) => row.name === "app.ts")!
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
})
test("selects a file tree node and expands its parents for a patch file", () => {
test("finds the parent directories to expand for a patch file", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const selection = fileTreeFileSelection(tree, 1)
expect(selection?.highlightedNode).toBe(
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
)
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
})
test("prefers the selected file when choosing the single patch file", () => {
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
expect(singlePatchFileIndex(2, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, 3)).toBe(3)
})
test("orders patches by the flattened file tree order", () => {
@@ -284,20 +231,4 @@ describe("diff viewer file tree utilities", () => {
expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
})
test("sets only selected directory expansion", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
const expanded = allExpandedFileTreeDirectories(tree)
const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
expect(collapsed.has(src.id)).toBe(false)
const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
expect(reopened.has(src.id)).toBe(true)
expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
})
})
+7
View File
@@ -0,0 +1,7 @@
// A 96 x 48 PNG fixture: a golden sun over blue mountains and a lake.
export const diffImageFixture = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAIAAABhdOiYAAABX0lEQVR42u3XwQkCMRCF4eljwVoET3r1YAGCbCcebMMCrMEW7MWrLiysi+4ms5PJJDEP/lsu4WMGEtpf78gRgQBAAAIQgCSttse5o9ej/a0uoE6nj6kjNqKidX6NHDoyIypdZ2zk1REY6QPtDhdjncGoAKBOp89Ypy9roIFmnKUO3ygB0KSOohFTh2lkCuSg0WJapMMxsgNi6oQYCXS8RkZAi3RkRmIdh5HFO0hAI2AK1Jk0snhJB+owjVR0voyi/8VUaLxMijRz35EoQOo6k0aRdEKMKKHOl1FUHbERJaQZZ6AjM6KqdARGlJbGXmepEVWos8iI6tThG1ESmhx0mEZUsw7HiCrX8RoRdPrmLkxNe0aOAAQgAE32vJ3GAQgTBKBIyxW4aNSsN58wMh4gSHGBipVy7Jdsy3xA1Y8VgBSB8hb0Lpds0VSB/nESUwAVhZgrUDaOhQPFR6wAKMzxDTghptXEGbWAAAAAAElFTkSuQmCC",
),
(character) => character.charCodeAt(0),
)
+34 -23
View File
@@ -101,29 +101,40 @@ Unknown command IDs are rejected.
## Diff Viewer
| ID | Default | Description |
| ----------------------- | ----------------- | ---------------------------------------- |
| `diff.open` | `none` | Open diff viewer |
| `diff.close` | `escape,q` | Close diff viewer |
| `diff.down` | `j,down` | Move diff viewer down |
| `diff.up` | `k,up` | Move diff viewer up |
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
| `diff.toggle` | `enter,space` | Toggle diff viewer item |
| `diff.expand` | `right` | Expand diff viewer item |
| `diff.expand_all` | `E` | Expand all diff viewer folders |
| `diff.collapse` | `left` | Collapse diff viewer item |
| `diff.switch_focus` | `tab` | Switch diff viewer focus |
| `diff.next_hunk` | `]` | Jump to next diff hunk |
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
| `diff.next_file` | `n` | Jump to next diff file |
| `diff.previous_file` | `p` | Jump to previous diff file |
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
| `diff.single_patch` | `s` | Toggle single patch view |
| `diff.switch_source` | `d` | Switch diff viewer source |
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
| `diff.help` | `?` | Show more diff viewer shortcuts |
Scrolling, paging, and start/end shortcuts always control the diff. There is no keyboard focus switch: click files to open them, click folders to expand or collapse them, and use the mouse wheel to scroll the file tree.
Use `n` / `p` or `alt+down` / `alt+up` (Option on macOS) to move between files. Inside the diff viewer, the Alt shortcuts navigate files rather than session tabs.
Press `m` to mark a file reviewed and collapse its patch body. In all-files view, press `m` again to unmark it and reopen the patch. Single-file view automatically advances to the next file when marking reviewed, but stays on the last file rather than wrapping. Unmarking a file never advances.
Right-click a file in the sidebar or its diff heading for **Mark complete** (or **Mark incomplete**). This changes the same reviewed state as `m`. Completing a different file from its menu does not select it or advance the file currently being read.
The shifted `diff.help` alternatives support terminals that report the Shift modifier when you press `?`.
| ID | Default | Description |
| ----------------------- | ------------------- | ---------------------------------------- |
| `diff.open` | `none` | Open diff viewer |
| `diff.close` | `escape,q` | Close diff viewer |
| `diff.down` | `j,down` | Move diff viewer down |
| `diff.up` | `k,up` | Move diff viewer up |
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
| `diff.half_page.down` | `ctrl+d` | Scroll diff viewer down half a page |
| `diff.half_page.up` | `ctrl+u` | Scroll diff viewer up half a page |
| `diff.first` | `gg,home` | Go to the start of the diff |
| `diff.last` | `shift+g,end` | Go to the end of the diff |
| `diff.next_hunk` | `]` | Jump to next diff hunk |
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
| `diff.next_file` | `n,alt+down` | Jump to next diff file |
| `diff.previous_file` | `p,alt+up` | Jump to previous diff file |
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
| `diff.single_patch` | `s` | Toggle single patch view |
| `diff.switch_source` | `d` | Switch diff viewer source |
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
| `diff.help` | `?,shift+?,shift+/` | Show more diff viewer shortcuts |
The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, and `diff.switch_focus` configuration entries are still accepted, but no longer register shortcuts.
## Appearance And Navigation