mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3340936d | |||
| f981861b67 | |||
| 748b0d8836 | |||
| ed1636e3bd | |||
| 90a87b2a0c | |||
| af8480b2e2 | |||
| 2096adae12 | |||
| 41349ff20a | |||
| a2f5a0df5d | |||
| 3bc924252b | |||
| 8c1808ab74 | |||
| 685f887771 | |||
| 5d87c7ad1f | |||
| fce506b3f9 | |||
| 9db5073eab | |||
| 775fce0177 | |||
| 4e74f77e44 | |||
| b980f74ed9 | |||
| 2987c30238 | |||
| 50a1add183 | |||
| 3533047421 | |||
| 823bc20427 |
-245
@@ -1,245 +0,0 @@
|
||||
# OpenCode Session Runtime
|
||||
|
||||
OpenCode sessions preserve durable conversational history while assembling the runtime context an agent needs to act correctly in its current environment.
|
||||
|
||||
## Language
|
||||
|
||||
**Model Context**:
|
||||
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
|
||||
_Avoid_: System Context
|
||||
|
||||
**Instructions**:
|
||||
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
|
||||
_Avoid_: Model Context, System Context
|
||||
|
||||
**Session History**:
|
||||
The projected chronological conversation selected for a **Step** after applying the active compaction boundary and interleaving derived **Instruction Updates** from the current **Instruction Epoch**.
|
||||
_Avoid_: Session Context
|
||||
|
||||
**Instruction Source**:
|
||||
One independently read typed value within **Instructions**, represented by a stable namespaced key, canonical JSON codec, pure first/changed renderers, and an optional removal renderer.
|
||||
_Avoid_: Prompt fragment
|
||||
|
||||
**InstructionEntry**:
|
||||
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
|
||||
|
||||
**InstructionDiscovery**:
|
||||
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
|
||||
|
||||
**Instruction State**:
|
||||
The Session-owned projection cache of one instruction log fold: epoch start, values at that start, current values, and the last folded sequence. It is rebuilt from durable events and never authors model-visible facts.
|
||||
|
||||
**Instruction Update**:
|
||||
A durable `session.instructions.updated` value delta admitted at a **Safe Step Boundary**. Its model-visible System text is rendered from stored values at request assembly and is never persisted verbatim.
|
||||
_Avoid_: Correction, stored prose, raw text diff
|
||||
|
||||
**Initial Instructions**:
|
||||
The deterministic instruction text rendered from values at the current **Instruction Epoch** start and sent as provider-cache prefix state until completed compaction moves the epoch or Session movement or committed revert resets it.
|
||||
_Avoid_: Live system prompt
|
||||
|
||||
**Instruction Epoch**:
|
||||
The span between completed compactions. Its start is the last `session.compaction.ended` sequence, or the initial complete instruction delta when no prior epoch exists.
|
||||
|
||||
**Instruction Values**:
|
||||
The key-to-hash map produced by folding instruction deltas in durable sequence order. Hash bodies live once in the content-addressed instruction blob store.
|
||||
|
||||
**Unavailable Instruction Source**:
|
||||
An expected temporary inability to read an **Instruction Source** value; the runtime retains its prior effective value and emits no update, while an unavailable source blocks the initial complete delta.
|
||||
|
||||
**Safe Step Boundary**:
|
||||
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
|
||||
|
||||
**Admitted Prompt**:
|
||||
A durable user input accepted into the Session inbox but not yet included in **Session History**.
|
||||
|
||||
**Prompt Promotion**:
|
||||
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
|
||||
|
||||
**Step**:
|
||||
One logical LLM call spanning pre-flight instruction synchronization, input promotion, request build, and compaction check; the provider stream; and tool settlement.
|
||||
_Avoid_: provider turn, turn (unqualified)
|
||||
|
||||
**Physical Attempt**:
|
||||
One actual provider request on the wire in service of a **Step**; most Steps have one Physical Attempt, while overflow-triggered compaction recovery may give one Step two.
|
||||
|
||||
**Assistant Turn**:
|
||||
A reserved name for the not-yet-modeled unit containing all **Steps** from prompt promotion until the assistant yields the floor; do not reify it until something durable needs it.
|
||||
|
||||
**Settlement**:
|
||||
The terminal transition for a unit of work: Step and tool settlement are durable, while drain and execution settlement are coordinator-observed.
|
||||
|
||||
**Execution**:
|
||||
One session-scoped coordinator busy period from first wake until idle. An Execution is process-local coordination rather than a durable domain entity.
|
||||
|
||||
**Session Drain**:
|
||||
One process-local execution span that promotes eligible input and runs required **Steps** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary.
|
||||
|
||||
**Model Tool Output**:
|
||||
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
|
||||
|
||||
**Managed Tool Output File**:
|
||||
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
|
||||
|
||||
**Model Request Options**:
|
||||
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
|
||||
_Avoid_: Request body, wire options
|
||||
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**Native Continuation Metadata**:
|
||||
Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
**OpenCode Client**:
|
||||
The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers.
|
||||
_Avoid_: Remote client
|
||||
|
||||
**SDK Contract IR**:
|
||||
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
|
||||
|
||||
**Embedded OpenCode**:
|
||||
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
|
||||
_Avoid_: Local implementation
|
||||
|
||||
**Page**:
|
||||
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
|
||||
_Avoid_: Response envelope
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
|
||||
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, **Initial Instructions**, **Session History**, available tools, and step-local additions into one model request.
|
||||
- **Session History** persists conversational messages. The runner derives model-facing **Instruction Update** messages from value deltas and interleaves them by durable sequence; **Initial Instructions** remain separate provider-request state.
|
||||
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
|
||||
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
|
||||
- Each **Instruction Source** read returns one coherent typed value, explicit removal, or temporary unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its canonical codec defines storage and hash equivalence, while pure renderers produce first, changed, and optional removal text.
|
||||
- `Instructions.read(...)` reads every composed source concurrently and exactly once at the boundary. `Instructions.diff(...)` compares encoded-value hashes with current **Instruction Values** and returns one delta plus new blob bodies.
|
||||
- `Instructions.renderInitial(...)` renders values at the **Instruction Epoch** start. `Instructions.renderUpdate(...)` renders one hydrated delta against the values immediately before it.
|
||||
- A changed **Instruction Source** contributes its hash to one **Instruction Update**; explicit removal contributes the `"removed"` sentinel.
|
||||
- An **Instruction Update** persists only its value delta. Rendered text is derived during request assembly and excluded from compaction summaries.
|
||||
- The instruction blob insert, durable delta, and **Instruction State** advance commit atomically.
|
||||
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
|
||||
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
|
||||
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
|
||||
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
|
||||
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
|
||||
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
|
||||
- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another.
|
||||
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
|
||||
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
|
||||
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
|
||||
- The first **Step** admits one complete delta and renders **Initial Instructions** without narrating that delta in history; an unavailable initial source blocks the Step instead of persisting incomplete values.
|
||||
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
|
||||
- Completed compaction moves the **Instruction Epoch** to the exact `session.compaction.ended` sequence and copies current hashes to the epoch's initial values. Earlier updates leave active model history while durable deltas remain.
|
||||
- A newly composed **Instruction Source** absent from current **Instruction Values** emits its first rendering once at the next **Safe Step Boundary**.
|
||||
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
|
||||
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
|
||||
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
|
||||
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
|
||||
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
|
||||
- Moving a Session clears its **Instruction State**, so the destination must admit a complete delta before another prompt can promote. Committed revert does the same; replay derives both resets from their durable events.
|
||||
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
|
||||
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
|
||||
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
|
||||
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
|
||||
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
|
||||
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
|
||||
- **Instruction Updates** remain durable value history but are not `session_message` rows. Clients display changed keys rather than model-facing prose.
|
||||
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
||||
- **Initial Instructions** are recomputed deterministically from durable values for every request; rendered bytes are not stored.
|
||||
- A model/provider switch preserves current **Instruction Values**, the **Instruction Epoch**, and chronological conversation history; the new selection applies to the next **Step**.
|
||||
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
|
||||
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
|
||||
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
|
||||
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
|
||||
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
|
||||
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
|
||||
- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
|
||||
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
|
||||
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
|
||||
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
|
||||
- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
|
||||
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
|
||||
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
|
||||
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
|
||||
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
|
||||
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
|
||||
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
|
||||
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
|
||||
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
|
||||
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
|
||||
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
|
||||
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
|
||||
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
|
||||
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
|
||||
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
|
||||
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
|
||||
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
|
||||
- `sessions.log({ sessionID, after, follow })` is the public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, optionally continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
|
||||
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
|
||||
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
|
||||
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
|
||||
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
|
||||
- `sessions.log({ sessionID, after, follow })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
|
||||
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
|
||||
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
|
||||
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
|
||||
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
|
||||
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
|
||||
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
|
||||
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
|
||||
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose agent system text, **Initial Instructions**, tools, and step-local additions remain separate.
|
||||
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
|
||||
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
|
||||
- The public operation remains `sessions.prompt(...)`; `SessionPending.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
|
||||
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
|
||||
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
|
||||
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
|
||||
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
|
||||
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
|
||||
- A truncated **Model Tool Output** identifies its complete text in the bounded model-visible preview. The Tool Registry also supplies managed paths as internal metadata to tool hooks; Session events do not expose a typed `outputPaths` field.
|
||||
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
|
||||
- Failure to retain a **Managed Tool Output File** fails settlement operationally. The Session never publishes a successful result whose complete output was lost during generic bounding.
|
||||
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
|
||||
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
|
||||
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
|
||||
- **Managed Tool Output Files** use globally unique names in one shared flat directory. They receive no special filesystem authority; each tool applies its ordinary external-path policy.
|
||||
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
|
||||
|
||||
## Client contract architecture
|
||||
|
||||
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
|
||||
|
||||
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
|
||||
|
||||
Before stabilizing the client API:
|
||||
|
||||
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
|
||||
- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
|
||||
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
|
||||
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
|
||||
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
|
||||
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
|
||||
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
|
||||
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
|
||||
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.
|
||||
@@ -1,73 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/FreeModelBadge"
|
||||
const projectID = "proj_free_model_badge"
|
||||
const sessionID = "ses_free_model_badge"
|
||||
|
||||
test("shows the Free badge instead of repeating Free in an OpenCode model name", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "free-model-badge",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"glm-5-free": {
|
||||
id: "glm-5-free",
|
||||
name: "GLM 5 Free",
|
||||
cost: { input: 0 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "glm-5-free" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "free-model-badge",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Free model badge",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="session-composer"]')
|
||||
await expectAppVisible(composer)
|
||||
|
||||
const trigger = composer.locator('[data-action="prompt-model"]')
|
||||
await expect(trigger).toContainText("GLM 5")
|
||||
await expect(trigger).toContainText("Free")
|
||||
await expect(trigger).not.toContainText("GLM 5 Free")
|
||||
|
||||
await trigger.click()
|
||||
const option = page.getByRole("dialog").getByRole("button", { name: /GLM 5/ })
|
||||
await expect(option).toBeVisible()
|
||||
await expect(option).toContainText("GLM 5")
|
||||
await expect(option).toContainText("Free")
|
||||
await expect(option).not.toContainText("GLM 5 Free")
|
||||
|
||||
await page.screenshot({ path: "e2e/test-results/free-model-badge.png" })
|
||||
})
|
||||
@@ -11,7 +11,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -27,6 +26,9 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
const c = model.current()
|
||||
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
@@ -81,15 +83,15 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{modelDisplayName(item)}</span>
|
||||
<Show when={isFreeModel(item)}>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
|
||||
@@ -58,7 +57,13 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
@@ -72,7 +77,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2.5">
|
||||
<span>{modelDisplayName(i)}</span>
|
||||
<span>{i.name}</span>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
<Show when={i.latest}>
|
||||
<Tag>{language.t("model.tag.latest")}</Tag>
|
||||
|
||||
@@ -31,7 +31,9 @@ import { decode64 } from "@/utils/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
@@ -92,7 +94,7 @@ const ModelList: Component<{
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} />}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
@@ -106,8 +108,8 @@ const ModelList: Component<{
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{modelDisplayName(i)}</span>
|
||||
<Show when={isFreeModel(i)}>
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={isFree(i.provider.id, i.cost)}>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
@@ -451,7 +453,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFreeModel(item)} v2 />}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
@@ -465,8 +474,8 @@ export function ModelSelectorPopoverV2(props: {
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{modelDisplayName(item)}</span>
|
||||
<Show when={isFreeModel(item)}>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isFreeModel, modelDisplayName } from "./model-display"
|
||||
|
||||
const model = (name: string, provider = "opencode", input = 0) => ({
|
||||
name,
|
||||
provider: { id: provider },
|
||||
cost: { input },
|
||||
})
|
||||
|
||||
describe("model display", () => {
|
||||
test("recognizes free models provided by OpenCode", () => {
|
||||
expect(isFreeModel(model("GLM 5 Free"))).toBe(true)
|
||||
expect(isFreeModel(model("GLM 5 Free", "openrouter"))).toBe(false)
|
||||
expect(isFreeModel(model("GLM 5 Free", "opencode", 1))).toBe(false)
|
||||
})
|
||||
|
||||
test("removes the standalone free label from free model names", () => {
|
||||
expect(modelDisplayName(model("GLM 5 Free"))).toBe("GLM 5")
|
||||
expect(modelDisplayName(model("GLM 5 (Free)"))).toBe("GLM 5")
|
||||
expect(modelDisplayName(model("Free GLM 5"))).toBe("GLM 5")
|
||||
})
|
||||
|
||||
test("preserves names that are not OpenCode free models", () => {
|
||||
expect(modelDisplayName(model("GLM 5 Free", "openrouter"))).toBe("GLM 5 Free")
|
||||
expect(modelDisplayName(model("FreeModel"))).toBe("FreeModel")
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
type ModelDisplay = {
|
||||
name: string
|
||||
provider: { id: string }
|
||||
cost?: { input: number }
|
||||
}
|
||||
|
||||
export const isFreeModel = (model: ModelDisplay) =>
|
||||
model.provider.id === "opencode" && (!model.cost || model.cost.input === 0)
|
||||
|
||||
export const modelDisplayName = (model: ModelDisplay) => {
|
||||
if (!isFreeModel(model)) return model.name
|
||||
return model.name
|
||||
.replace(/\(\s*free\s*\)/gi, " ")
|
||||
.replace(/\bfree\b/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { modelDisplayName } from "./model-display"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
type InputMap = Record<InputKey, boolean>
|
||||
@@ -9,12 +8,8 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
cost?: {
|
||||
input: number
|
||||
}
|
||||
capabilities?: {
|
||||
reasoning: boolean
|
||||
input: InputMap
|
||||
@@ -65,14 +60,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${modelDisplayName(props.model)}${suffix}`
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${modelDisplayName(props.model)}${suffix}`
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
const inputs = () => {
|
||||
if (props.model.capabilities) {
|
||||
|
||||
@@ -41,15 +41,12 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import { isFreeModel, modelDisplayName } from "@/components/model-display"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -1544,30 +1541,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => {
|
||||
const current = props.controls.model.selection.current()
|
||||
return {
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: current?.provider?.id,
|
||||
modelName: current ? modelDisplayName(current) : language.t("dialog.model.select.title"),
|
||||
free: current ? isFreeModel(current) : false,
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}
|
||||
})
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
if (props.controls.newLayoutDesigns) {
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
|
||||
},
|
||||
}))
|
||||
|
||||
const newSession = () => props.variant === "new-session"
|
||||
const bindEditorRef = (el: HTMLDivElement) => {
|
||||
@@ -2185,7 +2178,6 @@ type ComposerModelControlState = {
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
free: boolean
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
@@ -2225,7 +2217,6 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
}
|
||||
|
||||
function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Show when={!props.state.loading}>
|
||||
<Show
|
||||
@@ -2264,9 +2255,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Show when={props.state.free}>
|
||||
<Tag>{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
@@ -2342,7 +2330,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
@@ -2355,11 +2342,6 @@ function ModelControlContent(props: { state: ComposerModelControlState; v2?: boo
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<Show when={props.state.free}>
|
||||
<Show when={props.v2} fallback={<Tag>{language.t("model.tag.free")}</Tag>}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
</Show>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
|
||||
@@ -627,7 +627,6 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
this.themes.splice(index, 1)
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -1023,7 +1022,6 @@ export class RunFooter implements FooterApi {
|
||||
void resolveRunTheme(this.renderer).then((theme) => {
|
||||
if (this.isGone) {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
return style ?? SyntaxStyle.fromTheme([])
|
||||
}
|
||||
|
||||
export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
|
||||
if (commit.kind === "reasoning") {
|
||||
return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
|
||||
}
|
||||
|
||||
export function entrySyntax(theme: RunTheme): SyntaxStyle {
|
||||
return syntax(theme.block.syntax)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export class RunScrollbackStream {
|
||||
}
|
||||
|
||||
active.renderable.fg = entryColor(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
|
||||
active.renderable.syntaxStyle = entrySyntax(theme)
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
@@ -165,7 +165,7 @@ export class RunScrollbackStream {
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
@@ -175,7 +175,7 @@ export class RunScrollbackStream {
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
content: "",
|
||||
syntaxStyle: entrySyntax(commit, this.theme),
|
||||
syntaxStyle: entrySyntax(this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
|
||||
@@ -84,7 +84,7 @@ export function RunEntryContent(props: {
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
||||
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
||||
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
||||
const syntax = createMemo(() => entrySyntax(theme()))
|
||||
const color = createMemo(() => entryColor(props.commit, theme()))
|
||||
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
||||
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
||||
|
||||
@@ -47,7 +47,6 @@ export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
@@ -172,42 +171,10 @@ function tint(base: RGBA, overlay: RGBA, value: number): RGBA {
|
||||
)
|
||||
}
|
||||
|
||||
function blend(color: RGBA, bg: RGBA): RGBA {
|
||||
if (color.a >= 1) {
|
||||
return color
|
||||
}
|
||||
|
||||
return RGBA.fromValues(
|
||||
bg.r + (color.r - bg.r) * color.a,
|
||||
bg.g + (color.g - bg.g) * color.a,
|
||||
bg.b + (color.b - bg.b) * color.a,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
function chroma(color: RGBA) {
|
||||
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
function opaqueSyntaxStyle(style: SyntaxStyle | undefined, bg: RGBA): SyntaxStyle | undefined {
|
||||
if (!style) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return SyntaxStyle.fromStyles(
|
||||
Object.fromEntries(
|
||||
[...style.getAllStyles()].map(([name, value]) => [
|
||||
name,
|
||||
{
|
||||
...value,
|
||||
fg: value.fg ? blend(value.fg, bg) : value.fg,
|
||||
bg: value.bg ? blend(value.bg, bg) : value.bg,
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function indexedPalette(colors: TerminalColors, size: number = Math.max(colors.palette.length, 16)): RGBA[] {
|
||||
return Array.from({ length: size }, (_, index) => {
|
||||
const value = colors.palette[index]
|
||||
@@ -502,10 +469,7 @@ function map(
|
||||
scrollbackTheme: TuiThemeCurrent,
|
||||
splash: RunSplashTheme,
|
||||
syntax?: SyntaxStyle,
|
||||
subtleSyntax?: SyntaxStyle,
|
||||
): RunTheme {
|
||||
const opaqueSubtleSyntax = opaqueSyntaxStyle(subtleSyntax, scrollbackTheme.background)
|
||||
subtleSyntax?.destroy()
|
||||
const footerBackground = alpha(footerTheme.background, 1)
|
||||
const footerMode = mode(footerBackground)
|
||||
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
|
||||
@@ -566,7 +530,6 @@ function map(
|
||||
text: scrollbackTheme.text,
|
||||
muted: scrollbackTheme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax: opaqueSubtleSyntax,
|
||||
diffAdded: scrollbackTheme.diffAdded,
|
||||
diffRemoved: scrollbackTheme.diffRemoved,
|
||||
diffAddedBg: transparent,
|
||||
@@ -677,13 +640,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
_hasSelectedListItemText: true,
|
||||
}
|
||||
const syntax = shared.generateSyntax(syntaxTheme)
|
||||
return map(
|
||||
footerTheme,
|
||||
scrollbackTheme,
|
||||
splashTheme(scrollbackTheme, indexed),
|
||||
syntax,
|
||||
shared.generateSubtleSyntax(syntaxTheme),
|
||||
)
|
||||
return map(footerTheme, scrollbackTheme, splashTheme(scrollbackTheme, indexed), syntax)
|
||||
} catch {
|
||||
return RUN_THEME_FALLBACK
|
||||
}
|
||||
|
||||
@@ -35,9 +35,23 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
path.join(directory, "kv.json"),
|
||||
JSON.stringify({
|
||||
theme_mode_lock: "light",
|
||||
attention_sound_pack: "custom.pack",
|
||||
diff_wrap_mode: "none",
|
||||
diff_viewer_show_file_tree: false,
|
||||
diff_viewer_single_patch: true,
|
||||
diff_viewer_view: "split",
|
||||
terminal_title_enabled: false,
|
||||
file_context_enabled: false,
|
||||
paste_summary_enabled: false,
|
||||
sidebar: "hide",
|
||||
scrollbar_visible: true,
|
||||
thinking_mode: "show",
|
||||
exploration_grouping: false,
|
||||
tips_hidden: true,
|
||||
dismissed_getting_started: true,
|
||||
animations_enabled: false,
|
||||
skipped_version: "9.9.9",
|
||||
which_key_layout: "overlay",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -56,12 +70,17 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
diffs: { view: "unified" },
|
||||
prompt: { paste: "full" },
|
||||
session: { grouping: "none" },
|
||||
hints: { tips: false },
|
||||
attention: { sound_pack: "custom.pack" },
|
||||
diffs: { wrap: "none", tree: false, single: true, view: "split" },
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { tips: false, onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
|
||||
+88
-16
@@ -15,20 +15,43 @@ import type {
|
||||
SharedV3ProviderOptions,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
FinishReason,
|
||||
InvalidProviderOutputReason,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
LLMEvent,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
NotFound,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
classifyApiFailure,
|
||||
isLLMError,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/llm"
|
||||
import {
|
||||
APICallError,
|
||||
EmptyResponseBodyError,
|
||||
InvalidArgumentError,
|
||||
InvalidPromptError,
|
||||
InvalidResponseDataError,
|
||||
JSONParseError,
|
||||
LoadAPIKeyError,
|
||||
LoadSettingError,
|
||||
NoContentGeneratedError,
|
||||
NoSuchModelError,
|
||||
TypeValidationError,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -490,12 +513,12 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
catch: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
Stream.fromReadableStream({
|
||||
evaluate: () => result.stream,
|
||||
onError: (error) => llmError("readStream", error),
|
||||
onError: (error) => llmError(error),
|
||||
}).pipe(
|
||||
Stream.mapEffect((event) => streamPartEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
@@ -608,7 +631,7 @@ function streamPartEvents(
|
||||
}),
|
||||
])
|
||||
case "error":
|
||||
return Effect.fail(llmError("stream", event.error))
|
||||
return Effect.fail(llmError(event.error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,16 +689,65 @@ function messageValue(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof LLMError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
return new LLMError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
reason,
|
||||
})
|
||||
const BODY_LIMIT = 16_384
|
||||
|
||||
const headerRetryAfterMs = (headers: Record<string, string> | undefined) => {
|
||||
if (!headers) return undefined
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
const value = headers["retry-after"]
|
||||
if (!value) return undefined
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
|
||||
const date = Date.parse(value)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Classify AI SDK failures into the shared `LLMError` union so the synthetic
|
||||
// AI SDK route reports failures identically to native protocol routes. An
|
||||
// `APICallError` without a status code is the AI SDK's representation of a
|
||||
// network-level failure (connect refused, reset, DNS), not an API rejection.
|
||||
function llmError(error: unknown): LLMError {
|
||||
if (isLLMError(error)) return error
|
||||
if (APICallError.isInstance(error)) {
|
||||
if (error.statusCode === undefined) {
|
||||
return new ConnectionError({ message: error.message, url: error.url, cause: error })
|
||||
}
|
||||
return classifyApiFailure({
|
||||
message: error.message,
|
||||
status: error.statusCode,
|
||||
retryAfterMs: headerRetryAfterMs(error.responseHeaders),
|
||||
requestID: error.responseHeaders?.["x-request-id"] ?? error.responseHeaders?.["request-id"],
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: error.url, headers: {} }),
|
||||
response: new HttpResponseDetails({ status: error.statusCode, headers: error.responseHeaders ?? {} }),
|
||||
body: error.responseBody === undefined ? undefined : error.responseBody.slice(0, BODY_LIMIT),
|
||||
bodyTruncated: error.responseBody !== undefined && error.responseBody.length > BODY_LIMIT ? true : undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (LoadAPIKeyError.isInstance(error) || LoadSettingError.isInstance(error)) {
|
||||
return new Authentication({ message: error.message })
|
||||
}
|
||||
if (NoSuchModelError.isInstance(error)) return new NotFound({ message: error.message })
|
||||
if (
|
||||
InvalidPromptError.isInstance(error) ||
|
||||
InvalidArgumentError.isInstance(error) ||
|
||||
UnsupportedFunctionalityError.isInstance(error)
|
||||
) {
|
||||
return new BadRequest({ message: error.message })
|
||||
}
|
||||
if (
|
||||
InvalidResponseDataError.isInstance(error) ||
|
||||
JSONParseError.isInstance(error) ||
|
||||
TypeValidationError.isInstance(error) ||
|
||||
EmptyResponseBodyError.isInstance(error) ||
|
||||
NoContentGeneratedError.isInstance(error)
|
||||
) {
|
||||
return new MalformedResponse({ message: error.message })
|
||||
}
|
||||
return new APIError({ message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, type LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest, type Model } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "../config"
|
||||
@@ -247,11 +247,6 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
|
||||
@@ -261,7 +256,7 @@ const make = (dependencies: Dependencies) => {
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("LLM.Error", (error) =>
|
||||
Effect.catchIf(isLLMError, (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
export * as SessionRunnerLLM from "./llm"
|
||||
|
||||
import {
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, SystemPart, isLLMError, type LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
@@ -227,17 +218,10 @@ const layer = Layer.effect(
|
||||
// mid-event.
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
|
||||
overflowFailure = event
|
||||
return
|
||||
}
|
||||
}
|
||||
if (publisher.hasProviderError()) return
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
@@ -317,22 +301,21 @@ const layer = Layer.effect(
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
const llmFailure = streamFailure !== undefined && isLLMError(streamFailure) ? streamFailure : undefined
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasRetryEvidence() &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
llmFailure?._tag === "LLM.ContextOverflow" &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status ===
|
||||
"completed"
|
||||
)
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the step's durable provider error. A
|
||||
// thrown LLM failure records the assistant failure unless a provider error was
|
||||
// already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
// A thrown LLM failure records the assistant failure unless a provider failure
|
||||
// was already recorded from the stream. Terminal publication waits for owned tools.
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
const error = toSessionError(llmFailure)
|
||||
if (
|
||||
@@ -349,7 +332,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
yield* serialized(publisher.failAssistant(error))
|
||||
}
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
// The provider-failed flag is only set while consuming the stream (content-filter
|
||||
// step finish), so it is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
|
||||
@@ -543,7 +527,8 @@ const layer = Layer.effect(
|
||||
needsContinuation = result.needsContinuation
|
||||
step = result.step + 1
|
||||
if (needsContinuation) {
|
||||
promotion = (yield* SessionPending.compaction(db, input.sessionID)) ? undefined : "steer"
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
promotion = "steer"
|
||||
continue
|
||||
}
|
||||
yield* runPendingCompaction(input.sessionID)
|
||||
|
||||
@@ -438,10 +438,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
return
|
||||
case "finish":
|
||||
return
|
||||
case "provider-error":
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunnerRetry from "./retry"
|
||||
|
||||
import { LLMError } from "@opencode-ai/llm"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Data, Duration, Effect, Schedule } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
@@ -17,29 +17,33 @@ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableF
|
||||
}> {}
|
||||
|
||||
export function isRetryable(error: LLMError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
case "Transport":
|
||||
switch (error._tag) {
|
||||
case "LLM.RateLimit":
|
||||
case "LLM.ServerError":
|
||||
case "LLM.ConnectionError":
|
||||
case "LLM.TimeoutError":
|
||||
return true
|
||||
case "Authentication":
|
||||
case "QuotaExceeded":
|
||||
case "ContentPolicy":
|
||||
case "InvalidProviderOutput":
|
||||
case "InvalidRequest":
|
||||
case "NoRoute":
|
||||
case "UnknownProvider":
|
||||
case "LLM.Authentication":
|
||||
case "LLM.PermissionDenied":
|
||||
case "LLM.NotFound":
|
||||
case "LLM.QuotaExceeded":
|
||||
case "LLM.ContentPolicy":
|
||||
case "LLM.ContextOverflow":
|
||||
case "LLM.MalformedResponse":
|
||||
case "LLM.BadRequest":
|
||||
case "LLM.NoRoute":
|
||||
case "LLM.APIError":
|
||||
return false
|
||||
default: {
|
||||
const exhaustive: never = error.reason
|
||||
const exhaustive: never = error
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = (failure: RetryableFailure) => {
|
||||
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
|
||||
return failure.cause.reason.retryAfterMs
|
||||
if (failure.cause._tag === "LLM.RateLimit" || failure.cause._tag === "LLM.ServerError")
|
||||
return failure.cause.retryAfterMs
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMEvent, Message, isLLMError, type LLMError, type LLMRequest } from "@opencode-ai/llm"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
@@ -49,7 +49,6 @@ const make = (dependencies: Dependencies) => {
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
@@ -61,14 +60,13 @@ const make = (dependencies: Dependencies) => {
|
||||
)
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
||||
Effect.catchIf(isLLMError, () => Effect.succeed(false)),
|
||||
)
|
||||
if (!streamed || failed) return
|
||||
if (!streamed) return
|
||||
const title = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { isLLMError, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { PermissionV2 } from "../permission"
|
||||
@@ -9,30 +9,38 @@ import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./err
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
if (isLLMError(cause)) {
|
||||
switch (cause._tag) {
|
||||
case "LLM.RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.message }
|
||||
case "LLM.Authentication":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.PermissionDenied":
|
||||
return { type: "provider.auth", message: cause.message }
|
||||
case "LLM.NotFound":
|
||||
return { type: "provider.not-found", message: cause.message }
|
||||
case "LLM.QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.message }
|
||||
case "LLM.ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.message }
|
||||
case "LLM.ContextOverflow":
|
||||
return { type: "provider.context-overflow", message: cause.message }
|
||||
case "LLM.ConnectionError":
|
||||
return { type: "provider.transport", message: cause.message }
|
||||
case "LLM.TimeoutError":
|
||||
return { type: "provider.timeout", message: cause.message }
|
||||
case "LLM.ServerError":
|
||||
return { type: "provider.internal", message: cause.message }
|
||||
case "LLM.MalformedResponse":
|
||||
return { type: "provider.invalid-output", message: cause.message }
|
||||
case "LLM.BadRequest":
|
||||
return { type: "provider.invalid-request", message: cause.message }
|
||||
case "LLM.NoRoute":
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
case "LLM.APIError":
|
||||
return { type: "provider.unknown", message: cause.message }
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
const exhaustive: never = cause
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
NoRouteReason,
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
MalformedResponse,
|
||||
ModelID,
|
||||
NoRoute,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderID,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
RouteID,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
ToolFailure,
|
||||
} from "@opencode-ai/llm"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -20,39 +24,33 @@ import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
|
||||
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
|
||||
|
||||
describe("toSessionError", () => {
|
||||
test("maps every LLM reason to the open wire type", () => {
|
||||
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
|
||||
test("maps every LLM error tag to the open wire type", () => {
|
||||
expect(toSessionError(new RateLimit({ message: "rate", retryAfterMs: 123 }))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "rate",
|
||||
})
|
||||
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
|
||||
"provider.auth",
|
||||
)
|
||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||
"provider.internal",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
|
||||
"provider.invalid-output",
|
||||
)
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
|
||||
expect(toSessionError(new Authentication({ message: "auth" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new PermissionDenied({ message: "forbidden" })).type).toBe("provider.auth")
|
||||
expect(toSessionError(new NotFound({ message: "missing" })).type).toBe("provider.not-found")
|
||||
expect(toSessionError(new QuotaExceeded({ message: "quota" })).type).toBe("provider.quota")
|
||||
expect(toSessionError(new ContentPolicy({ message: "blocked" })).type).toBe("provider.content-filter")
|
||||
expect(toSessionError(new ContextOverflow({ message: "too long" })).type).toBe("provider.context-overflow")
|
||||
expect(toSessionError(new ConnectionError({ message: "reset" })).type).toBe("provider.transport")
|
||||
expect(toSessionError(new TimeoutError({ message: "timed out" })).type).toBe("provider.timeout")
|
||||
expect(toSessionError(new ServerError({ message: "internal", status: 500 })).type).toBe("provider.internal")
|
||||
expect(toSessionError(new MalformedResponse({ message: "output" })).type).toBe("provider.invalid-output")
|
||||
expect(toSessionError(new BadRequest({ message: "request" })).type).toBe("provider.invalid-request")
|
||||
expect(
|
||||
toSessionError(
|
||||
llm(
|
||||
new NoRouteReason({
|
||||
route: "route",
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
).type,
|
||||
).toBe("provider.no-route")
|
||||
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
|
||||
expect(toSessionError(new APIError({ message: "unknown", status: 418 })).type).toBe("provider.unknown")
|
||||
})
|
||||
|
||||
test("preserves the permission rejection type without exposing internal fields", () => {
|
||||
@@ -71,23 +69,31 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
test("retries only rate limits, server errors, connection failures, and timeouts", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
new RateLimit({ message: "rate" }),
|
||||
new ServerError({ message: "internal", status: 500 }),
|
||||
new ConnectionError({ message: "reset" }),
|
||||
new TimeoutError({ message: "timed out" }),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
llm(new QuotaExceededReason({ message: "quota" })),
|
||||
llm(new ContentPolicyReason({ message: "blocked" })),
|
||||
llm(new InvalidProviderOutputReason({ message: "output" })),
|
||||
llm(new InvalidRequestReason({ message: "request" })),
|
||||
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
|
||||
llm(new UnknownProviderReason({ message: "unknown" })),
|
||||
new Authentication({ message: "auth" }),
|
||||
new PermissionDenied({ message: "forbidden" }),
|
||||
new NotFound({ message: "missing" }),
|
||||
new QuotaExceeded({ message: "quota" }),
|
||||
new ContentPolicy({ message: "blocked" }),
|
||||
new ContextOverflow({ message: "too long" }),
|
||||
new MalformedResponse({ message: "output" }),
|
||||
new BadRequest({ message: "request" }),
|
||||
new NoRoute({
|
||||
route: RouteID.make("route"),
|
||||
provider: ProviderID.make("provider"),
|
||||
model: ModelID.make("model"),
|
||||
}),
|
||||
new APIError({ message: "unknown" }),
|
||||
]
|
||||
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual(ineligible.map(() => false))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { LLMError, TransportReason } from "@opencode-ai/llm"
|
||||
import { ConnectionError } from "@opencode-ai/llm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -25,17 +25,10 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Event
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
expect(SessionExecution.terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
|
||||
expect(
|
||||
SessionExecution.terminal(
|
||||
Exit.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
|
||||
expect(SessionExecution.terminal(Exit.fail(new ConnectionError({ message: "Disconnected" })))).toEqual({
|
||||
type: "failed",
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
})
|
||||
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
|
||||
expect(SessionExecution.terminal(Exit.fail(storage))).toEqual({
|
||||
type: "failed",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
APIError,
|
||||
BadRequest,
|
||||
ConnectionError,
|
||||
ContextOverflow,
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
MalformedResponse,
|
||||
Model,
|
||||
RateLimit,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
type LLMError,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
@@ -69,8 +71,9 @@ import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const requests: LLMRequest[] = []
|
||||
type ScriptedResponse = LLMEvent[] | Stream.Stream<LLMEvent, LLMError>
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
let responses: ScriptedResponse[] | undefined
|
||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||
let streamGate: Deferred.Deferred<void> | undefined
|
||||
@@ -93,9 +96,12 @@ const client = Layer.succeed(
|
||||
responseStream = undefined
|
||||
return stream
|
||||
}
|
||||
const scripted = responses === undefined ? response : (responses.shift() ?? [])
|
||||
const events = streamFailure
|
||||
? Stream.fail(streamFailure)
|
||||
: Stream.fromIterable(responses === undefined ? response : (responses.shift() ?? []))
|
||||
: Array.isArray(scripted)
|
||||
? Stream.fromIterable(scripted)
|
||||
: scripted
|
||||
if (!streamGate) return events
|
||||
return Stream.unwrap(
|
||||
(streamStarted ? Deferred.succeed(streamStarted, undefined) : Effect.void).pipe(
|
||||
@@ -483,26 +489,16 @@ const setup = Effect.gen(function* () {
|
||||
return yield* SessionV2.Service
|
||||
})
|
||||
|
||||
const providerUnavailable = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
})
|
||||
const providerUnavailable = () => new ConnectionError({ message: "Provider unavailable" })
|
||||
|
||||
const invalidRequest = () =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({ message: "Invalid request" }),
|
||||
})
|
||||
const contextOverflow = () => new ContextOverflow({ message: "prompt too long" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) =>
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new RateLimitReason({ message: "Rate limited", retryAfterMs }),
|
||||
})
|
||||
const failingResponse = (events: LLMEvent[], failure: LLMError): Stream.Stream<LLMEvent, LLMError> =>
|
||||
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
const invalidRequest = () => new BadRequest({ message: "Invalid request" })
|
||||
|
||||
const rateLimited = (retryAfterMs?: number) => new RateLimit({ message: "Rate limited", retryAfterMs })
|
||||
|
||||
const setupOverflowRecovery = Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1614,14 +1610,14 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs one durable compaction barrier before later steer and queued prompts", () =>
|
||||
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
currentModel = recoveryModel
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
responses = [
|
||||
reply.text("Active complete", "text-active"),
|
||||
reply.tool("call-active", "echo", { text: "active" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
|
||||
reply.text("Steer complete", "text-steer"),
|
||||
reply.text("Queue complete", "text-queue"),
|
||||
@@ -1758,14 +1754,14 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* admit(session, "Earlier question")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
response = [LLMEvent.providerError({ message: "summary unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "summary unavailable" }))
|
||||
const compaction = yield* session.compact({ sessionID })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1875,7 +1871,7 @@ describe("SessionRunnerLLM", () => {
|
||||
currentModel = compactModel
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "Unsupported parameter: max_output_tokens" })],
|
||||
Stream.fail(new BadRequest({ message: "Unsupported parameter: max_output_tokens" })),
|
||||
reply.text("Must not run", "text-after-failed-compaction"),
|
||||
]
|
||||
yield* admit(session, "Recent exact request ".repeat(180))
|
||||
@@ -1898,10 +1894,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
],
|
||||
failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow()),
|
||||
reply.text("## Objective\n- Recover overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
]
|
||||
@@ -1928,7 +1921,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = model
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover unknown limit", "text-summary-unknown-limit"),
|
||||
reply.text("Recovered", "text-final-unknown-limit"),
|
||||
]
|
||||
@@ -1948,7 +1941,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setupOverflowRecovery
|
||||
currentModel = undersizedContextModel
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
Stream.fail(contextOverflow()),
|
||||
reply.text("## Objective\n- Recover undersized limit", "text-summary-undersized-limit"),
|
||||
reply.text("Recovered", "text-final-undersized-limit"),
|
||||
]
|
||||
@@ -1966,10 +1959,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("persists a second context overflow after one recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const overflow = () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
const overflow = () => failingResponse([LLMEvent.stepStart({ index: 0 })], contextOverflow())
|
||||
responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
@@ -1985,16 +1975,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("recovers once from a raw context overflow failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responseStream = Stream.fail(
|
||||
new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidRequestReason({
|
||||
message: "prompt too long",
|
||||
classification: "context-overflow",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
responseStream = Stream.fail(contextOverflow())
|
||||
responses = [
|
||||
reply.text("## Objective\n- Recover raw overflow", "text-summary"),
|
||||
reply.text("Recovered", "text-final"),
|
||||
@@ -2013,10 +1994,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("publishes the original overflow when recovery summarization fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
[LLMEvent.providerError({ message: "summary unavailable" })],
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), Stream.fail(new APIError({ message: "summary unavailable" }))]
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
@@ -2027,7 +2005,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
error: { type: "provider.unknown", message: "summary unavailable" },
|
||||
}),
|
||||
)
|
||||
expect(context.slice(-3)).toMatchObject([
|
||||
@@ -2041,10 +2019,7 @@ describe("SessionRunnerLLM", () => {
|
||||
it.effect("interrupts overflow recovery while the summary provider is running", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
responses = [
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
reply.text("## Objective\n- Interrupted", "text-summary"),
|
||||
]
|
||||
responses = [Stream.fail(contextOverflow()), reply.text("## Objective\n- Interrupted", "text-summary")]
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const summaryGate = yield* Deferred.make<void>()
|
||||
streamGate = firstGate
|
||||
@@ -3627,7 +3602,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail durably")
|
||||
|
||||
response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 })],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3644,7 +3622,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail before step")
|
||||
|
||||
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
|
||||
responseStream = Stream.fail(new APIError({ message: "Provider unavailable" }))
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3732,13 +3710,15 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail after output")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "text-partial" }),
|
||||
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
|
||||
LLMEvent.textEnd({ id: "text-partial" }),
|
||||
],
|
||||
contextOverflow(),
|
||||
)
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3892,11 +3872,7 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
const failure = new MalformedResponse({ message: "Invalid JSON input for tool call echo" })
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
@@ -3935,11 +3911,13 @@ describe("SessionRunnerLLM", () => {
|
||||
toolExecutionGate = yield* Deferred.make<void>()
|
||||
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||
toolExecutionsReady = 1
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-error", name: "echo", input: { text: "settled" } }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(toolExecutionsStarted)
|
||||
@@ -3966,11 +3944,10 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Fail hosted tool durably")
|
||||
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
hostedCall("call-hosted-provider-error", "effect"),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-provider-error", "effect")],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
@@ -3997,11 +3974,13 @@ describe("SessionRunnerLLM", () => {
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Defect while provider fails")
|
||||
response = [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
LLMEvent.providerError({ message: "Provider unavailable" }),
|
||||
]
|
||||
responseStream = failingResponse(
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }),
|
||||
],
|
||||
new APIError({ message: "Provider unavailable" }),
|
||||
)
|
||||
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable")
|
||||
|
||||
|
||||
Vendored
+14
-5
@@ -1,12 +1,21 @@
|
||||
---
|
||||
title: "SDK"
|
||||
description: "Embed an OpenCode host in an Effect application."
|
||||
description: "Embed OpenCode directly in your application."
|
||||
---
|
||||
|
||||
`@opencode-ai/sdk-next` is the Effect-native SDK for applications that need to
|
||||
host OpenCode in-process. Unlike the [network client](/build/client), it assembles the
|
||||
OpenCode server and routes API calls through its HTTP router in memory. It opens
|
||||
no HTTP listener and adds no network hop between the client and server.
|
||||
We're working on a general-purpose SDK for embedding OpenCode directly inside
|
||||
your application. The regular SDK is coming soon.
|
||||
|
||||
An Effect-native version is available now for applications built with Effect.
|
||||
Its current documentation is below. For other applications, run OpenCode as a
|
||||
server and use the [TypeScript client](/build/client) in the meantime.
|
||||
|
||||
## Effect
|
||||
|
||||
`@opencode-ai/sdk-next` hosts OpenCode in-process. Unlike the
|
||||
[network client](/build/client), it assembles the OpenCode server and routes API
|
||||
calls through its HTTP router in memory. It opens no HTTP listener and adds no
|
||||
network hop between the client and server.
|
||||
|
||||
<Warning>
|
||||
The V2 SDK is beta and currently private to the OpenCode workspace. It is not
|
||||
|
||||
@@ -182,8 +182,8 @@ The dependency arrow points down: `providers/*.ts` files import protocol routes
|
||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `eventError(route, message, ...)` — typed `MalformedResponse` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `BadRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||
|
||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||
@@ -291,7 +291,7 @@ Use this order for every protocol module:
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event for each completed response. Provider-reported failures (SSE error events, exception frames) fail the stream with a typed `LLMError` via `classifyApiFailure` — never an ordinary event. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||
|
||||
@@ -2,7 +2,7 @@ export { LLMClient } from "./route/client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
|
||||
export { classifyApiFailure, isContextOverflow, type ApiFailure } from "./provider-error"
|
||||
export type {
|
||||
RouteModelInput,
|
||||
RouteRoutedModelInput,
|
||||
|
||||
+6
-14
@@ -3,8 +3,8 @@ import { LLMClient } from "./route/client"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputReason,
|
||||
LLMError,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
@@ -121,22 +121,14 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
|
||||
)
|
||||
if (!call || !LLMEvent.is.toolCall(call))
|
||||
return yield* new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
}),
|
||||
return yield* new MalformedResponse({
|
||||
message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`,
|
||||
})
|
||||
const object = yield* tool._decode(call.input).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new LLMError({
|
||||
module: "LLM",
|
||||
method: "generateObject",
|
||||
reason: new InvalidProviderOutputReason({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
new MalformedResponse({
|
||||
message: `generateObject: tool input failed schema decode: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -832,15 +832,11 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
return message || type || "Anthropic Messages stream error"
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message: providerErrorMessage(event),
|
||||
classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event),
|
||||
code: event.error?.type,
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
@@ -848,7 +844,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "error") return Effect.fail(onError(event))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { BedrockEventStream } from "./bedrock-event-stream"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
@@ -586,27 +586,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const
|
||||
}
|
||||
|
||||
if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) {
|
||||
const message =
|
||||
event.internalServerException?.message ??
|
||||
event.modelStreamErrorException?.message ??
|
||||
event.serviceUnavailableException?.message ??
|
||||
"Bedrock Converse stream error"
|
||||
return [state, [LLMEvent.providerError({ message })]] as const
|
||||
}
|
||||
|
||||
if (event.validationException || event.throttlingException) {
|
||||
const message =
|
||||
event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error"
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.providerError({
|
||||
message,
|
||||
classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
}),
|
||||
],
|
||||
const exception = (
|
||||
[
|
||||
["internalServerException", event.internalServerException],
|
||||
["modelStreamErrorException", event.modelStreamErrorException],
|
||||
["serviceUnavailableException", event.serviceUnavailableException],
|
||||
["throttlingException", event.throttlingException],
|
||||
["validationException", event.validationException],
|
||||
] as const
|
||||
).find((entry) => entry[1] !== undefined)
|
||||
if (exception) {
|
||||
return yield* classifyApiFailure({
|
||||
message: exception[1]?.message ?? "Bedrock Converse stream error",
|
||||
code: exception[0],
|
||||
})
|
||||
}
|
||||
|
||||
return [state, []] as const
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
@@ -606,9 +606,9 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` is a hard failure that emits a
|
||||
// `provider-error`. All three end the stream — kept in one set so `step` and
|
||||
// the protocol's `terminal` predicate stay in sync.
|
||||
// `finish` event; `response.failed` is a hard failure that fails the stream
|
||||
// with a classified `LLMError`. All three end the stream — kept in one set so
|
||||
// `step` and the protocol's `terminal` predicate stay in sync.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
@@ -907,24 +907,11 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
return LLMEvent.providerError({
|
||||
message,
|
||||
classification: code === "context_length_exceeded" || isContextOverflow(message) ? "context-overflow" : undefined,
|
||||
const providerError = (event: OpenAIResponsesEvent, fallback: string) =>
|
||||
classifyApiFailure({
|
||||
message: providerErrorMessage(event, fallback),
|
||||
code: event.code || event.error?.code || event.response?.error?.code || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses response failed")],
|
||||
]
|
||||
|
||||
const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[providerError(event, "OpenAI Responses stream error")],
|
||||
]
|
||||
|
||||
const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
@@ -950,8 +937,8 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
|
||||
if (event.type === "error") return Effect.succeed(onError(state, event))
|
||||
if (event.type === "response.failed") return Effect.fail(providerError(event, "OpenAI Responses response failed"))
|
||||
if (event.type === "error") return Effect.fail(providerError(event, "OpenAI Responses stream error"))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
BadRequest,
|
||||
MalformedResponse,
|
||||
type LLMError,
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -88,11 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
}
|
||||
|
||||
export const eventError = (route: string, message: string, raw?: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ route, message, raw }),
|
||||
})
|
||||
new MalformedResponse({ route, message, raw })
|
||||
|
||||
export const parseJson = (route: string, input: string, message: string) =>
|
||||
Effect.try({
|
||||
@@ -252,15 +248,9 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
|
||||
* Canonical invalid-request constructor. Lift one-line `const invalid =
|
||||
* (message) => invalidRequest(message)` aliases out of every
|
||||
* route so the error constructor lives in one place. If we ever extend
|
||||
* `InvalidRequestReason` with route context or trace metadata, the change
|
||||
* lands here.
|
||||
* `BadRequest` with route context or trace metadata, the change lands here.
|
||||
*/
|
||||
export const invalidRequest = (message: string) =>
|
||||
new LLMError({
|
||||
module: "ProviderShared",
|
||||
method: "request",
|
||||
reason: new InvalidRequestReason({ message }),
|
||||
})
|
||||
export const invalidRequest = (message: string) => new BadRequest({ message })
|
||||
|
||||
export const matchToolChoice = <Auto, None, Required, Tool>(
|
||||
route: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { isLLMError, LLMEvent, type LLMError, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -95,7 +95,7 @@ const appendTool = <K extends StreamKey>(
|
||||
}
|
||||
|
||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
||||
result instanceof LLMError
|
||||
isLLMError(result)
|
||||
|
||||
/**
|
||||
* Register a tool call whose start event arrived before any argument deltas.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLMError, ProviderErrorEvent } from "./schema"
|
||||
import {
|
||||
APIError,
|
||||
Authentication,
|
||||
BadRequest,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
ProviderMetadata,
|
||||
QuotaExceeded,
|
||||
RateLimit,
|
||||
ServerError,
|
||||
type LLMError,
|
||||
} from "./schema"
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
@@ -27,7 +41,102 @@ const patterns = [
|
||||
export const isContextOverflow = (message: string) =>
|
||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
const OVERFLOW_CODES = new Set(["context_length_exceeded", "model_context_window_exceeded"])
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_STATUS = (status: number) => status >= 500 || status === 529
|
||||
|
||||
const CODE_CLASSIFICATION: Record<string, (input: ApiFailure, common: CommonFields) => LLMError> = {
|
||||
overloaded_error: serverError,
|
||||
api_error: serverError,
|
||||
server_error: serverError,
|
||||
internal_error: serverError,
|
||||
server_is_overloaded: serverError,
|
||||
internalServerException: serverError,
|
||||
serviceUnavailableException: serverError,
|
||||
modelStreamErrorException: serverError,
|
||||
rate_limit_error: rateLimit,
|
||||
rate_limit_exceeded: rateLimit,
|
||||
too_many_requests: rateLimit,
|
||||
throttlingException: rateLimit,
|
||||
authentication_error: (_input, common) => new Authentication(common),
|
||||
permission_error: (_input, common) => new PermissionDenied(common),
|
||||
not_found_error: (_input, common) => new NotFound(common),
|
||||
invalid_request_error: (_input, common) => new BadRequest(common),
|
||||
invalid_prompt: (_input, common) => new BadRequest(common),
|
||||
validationException: (_input, common) => new BadRequest(common),
|
||||
}
|
||||
|
||||
export interface ApiFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
/** Provider machine-readable error code or type string (e.g. `context_length_exceeded`, `overloaded_error`). */
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly requestID?: string | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
readonly providerMetadata?: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
type CommonFields = {
|
||||
readonly message: string
|
||||
readonly status: number | undefined
|
||||
readonly code: string | undefined
|
||||
readonly requestID: string | undefined
|
||||
readonly http: HttpContext | undefined
|
||||
readonly providerMetadata: ProviderMetadata | undefined
|
||||
}
|
||||
|
||||
function serverError(input: ApiFailure, common: CommonFields) {
|
||||
return new ServerError({ ...common, retryAfterMs: input.retryAfterMs })
|
||||
}
|
||||
|
||||
function rateLimit(input: ApiFailure, common: CommonFields) {
|
||||
return new RateLimit({ ...common, retryAfterMs: input.retryAfterMs, rateLimit: input.rateLimit })
|
||||
}
|
||||
|
||||
/**
|
||||
* One classifier for every failure a remote API deliberately reports.
|
||||
* Protocols call it with in-stream error payloads, the request executor with
|
||||
* non-2xx responses, and the AI SDK adapter with `APICallError`s, so all
|
||||
* three surfaces produce identical `LLMError` tags.
|
||||
*
|
||||
* Precedence: context overflow (most specific, 4xx-scoped), content policy,
|
||||
* HTTP status, provider code, then the generic `APIError` fallback.
|
||||
*/
|
||||
export const classifyApiFailure = (input: ApiFailure): LLMError => {
|
||||
const common: CommonFields = {
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
requestID: input.requestID,
|
||||
http: input.http,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}
|
||||
const body = input.http?.body ?? ""
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
if (
|
||||
clientScoped &&
|
||||
((input.code !== undefined && OVERFLOW_CODES.has(input.code)) ||
|
||||
isContextOverflow(input.message) ||
|
||||
(body.length > 0 && isContextOverflow(body)))
|
||||
)
|
||||
return new ContextOverflow(common)
|
||||
if (CONTENT_POLICY_TEXT.test(body.length > 0 ? body : input.message)) return new ContentPolicy(common)
|
||||
if (input.code !== undefined && QUOTA_CODES.has(input.code)) return new QuotaExceeded(common)
|
||||
if (input.status === 401) return new Authentication(common)
|
||||
if (input.status === 403) return new PermissionDenied(common)
|
||||
if (input.status === 404) return new NotFound(common)
|
||||
if (input.status === 429) {
|
||||
if (QUOTA_TEXT.test(body.length > 0 ? body : input.message)) return new QuotaExceeded(common)
|
||||
return rateLimit(input, common)
|
||||
}
|
||||
if (input.status !== undefined && SERVER_ERROR_STATUS(input.status)) return serverError(input, common)
|
||||
if (input.status === 400 || input.status === 409 || input.status === 413 || input.status === 422)
|
||||
return new BadRequest(common)
|
||||
const byCode = input.code === undefined ? undefined : CODE_CLASSIFICATION[input.code]
|
||||
if (byCode) return byCode(input, common)
|
||||
return new APIError(common)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
||||
import { Authentication, BadRequest, type LLMError, type LLMRequest } from "../schema"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -135,16 +135,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
}
|
||||
|
||||
const toLLMError = (error: AuthError): LLMError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new LLMError({
|
||||
module: "Auth",
|
||||
method: "apply",
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationReason({ message: error.message, kind: "missing" })
|
||||
: new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }),
|
||||
})
|
||||
}
|
||||
if (error instanceof MissingCredentialError) return new Authentication({ message: error.message })
|
||||
if (error instanceof Config.ConfigError)
|
||||
return new BadRequest({ message: `Failed to resolve auth config: ${error.message}` })
|
||||
return error
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
isLLMError,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Model,
|
||||
ModelLimits,
|
||||
LLMError as LLMErrorClass,
|
||||
PreparedRequest,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
@@ -225,7 +225,7 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
|
||||
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed instanceof LLMErrorClass) return failed
|
||||
if (failed !== undefined && isLLMError(failed)) return failed
|
||||
return ProviderShared.eventError(route, message, Cause.pretty(cause))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -8,21 +8,15 @@ import {
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http"
|
||||
import {
|
||||
AuthenticationReason,
|
||||
ContentPolicyReason,
|
||||
ConnectionError,
|
||||
HttpContext,
|
||||
HttpRateLimitDetails,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
InvalidRequestReason,
|
||||
LLMError,
|
||||
ProviderInternalReason,
|
||||
QuotaExceededReason,
|
||||
RateLimitReason,
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
TimeoutError,
|
||||
type LLMError,
|
||||
} from "../schema"
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { classifyApiFailure } from "../provider-error"
|
||||
|
||||
export interface Interface {
|
||||
readonly execute: (
|
||||
@@ -85,8 +79,6 @@ const requestId = (headers: Record<string, string>) => {
|
||||
)
|
||||
}
|
||||
|
||||
const providerInternalStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -219,56 +211,21 @@ const responseHttp = (input: {
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
const statusReason = (input: {
|
||||
readonly status: number
|
||||
readonly message: string
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http: HttpContext
|
||||
}) => {
|
||||
const body = input.http.body ?? ""
|
||||
if (/content[-_\s]?policy|content_filter|safety/i.test(body)) {
|
||||
return new ContentPolicyReason({ message: input.message, http: input.http })
|
||||
}
|
||||
if (input.status === 401) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http })
|
||||
}
|
||||
if (input.status === 403) {
|
||||
return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http })
|
||||
}
|
||||
if (input.status === 429) {
|
||||
if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) {
|
||||
return new QuotaExceededReason({ message: input.message, http: input.http })
|
||||
}
|
||||
return new RateLimitReason({
|
||||
message: input.message,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
rateLimit: input.rateLimit,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 409 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
) {
|
||||
return new InvalidRequestReason({
|
||||
message: input.message,
|
||||
classification: isContextOverflow(body) ? "context-overflow" : undefined,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
if (input.status >= 500 || providerInternalStatus(input.status)) {
|
||||
return new ProviderInternalReason({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http })
|
||||
const decodeBodyJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
// Provider machine code from a JSON error body (`error.code` / `error.type`),
|
||||
// fed to the shared classifier so code-based rules (overflow, quota) work on
|
||||
// HTTP rejections too. Truncated or non-JSON bodies yield undefined.
|
||||
const providerCode = (body: string | undefined) => {
|
||||
if (!body) return undefined
|
||||
const decoded = Option.getOrUndefined(decodeBodyJson(body))
|
||||
if (typeof decoded !== "object" || decoded === null) return undefined
|
||||
const error = (decoded as Record<string, unknown>).error
|
||||
if (typeof error !== "object" || error === null) return undefined
|
||||
const fields = error as Record<string, unknown>
|
||||
if (typeof fields.code === "string") return fields.code
|
||||
if (typeof fields.type === "string") return fields.type
|
||||
return undefined
|
||||
}
|
||||
|
||||
const statusError =
|
||||
@@ -281,58 +238,55 @@ const statusError =
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
return yield* new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: statusReason({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
retryAfterMs: retryAfter,
|
||||
return yield* classifyApiFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, details),
|
||||
code: providerCode(details.body),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
requestID: requestId(headers),
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
const httpContext = (request: HttpClientRequest.HttpClientRequest | undefined) =>
|
||||
request ? new HttpContext({ request: requestDetails(request, redactedNames) }) : undefined
|
||||
const connectionError = (input: {
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||
}) =>
|
||||
new LLMError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: new TransportReason({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||
}),
|
||||
new ConnectionError({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: httpContext(input.request),
|
||||
cause: error,
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
return new TimeoutError({ message: error.message })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: "HTTP transport failed" })
|
||||
return connectionError({ message: "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
return transportError({
|
||||
return connectionError({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
})
|
||||
}
|
||||
return transportError({
|
||||
return connectionError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import { ConnectionError, type LLMError } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
@@ -27,15 +27,10 @@ type WebSocketConstructorWithHeaders = new (
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
_method: string,
|
||||
message: string,
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
) =>
|
||||
new LLMError({
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
})
|
||||
) => new ConnectionError({ message, url: input.url, kind: input.kind })
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
method: Schema.String,
|
||||
url: Schema.String,
|
||||
@@ -31,118 +28,150 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({
|
||||
_tag: Schema.tag("InvalidRequest"),
|
||||
/**
|
||||
* Fields shared by every failure the remote API deliberately reported —
|
||||
* whether as a non-2xx response, an SSE error event, a WebSocket error
|
||||
* message, or a binary exception frame. `status` is absent when the error
|
||||
* arrived mid-stream without an HTTP status; `code` carries the provider's
|
||||
* machine-readable error code (e.g. `context_length_exceeded`) when one
|
||||
* exists.
|
||||
*/
|
||||
const apiFailureFields = {
|
||||
message: Schema.String,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
status: Schema.optional(Schema.Number),
|
||||
code: Schema.optional(Schema.String),
|
||||
requestID: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({
|
||||
_tag: Schema.tag("NoRoute"),
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
get message() {
|
||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}
|
||||
|
||||
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({
|
||||
_tag: Schema.tag("Authentication"),
|
||||
message: Schema.String,
|
||||
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Provider rejected the request as invalid (400/409/422, `invalid_request_error`, ...). */
|
||||
export class BadRequest extends Schema.TaggedErrorClass<BadRequest>()("LLM.BadRequest", {
|
||||
...apiFailureFields,
|
||||
parameter: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({
|
||||
_tag: Schema.tag("RateLimit"),
|
||||
message: Schema.String,
|
||||
/** Credentials are missing, invalid, or expired (401). */
|
||||
export class Authentication extends Schema.TaggedErrorClass<Authentication>()("LLM.Authentication", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Authenticated but not allowed (403). */
|
||||
export class PermissionDenied extends Schema.TaggedErrorClass<PermissionDenied>()("LLM.PermissionDenied", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Model or endpoint does not exist (404). */
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("LLM.NotFound", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Transient request throttling (429). Retryable; honor `retryAfterMs` when present. */
|
||||
export class RateLimit extends Schema.TaggedErrorClass<RateLimit>()("LLM.RateLimit", {
|
||||
...apiFailureFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({
|
||||
_tag: Schema.tag("QuotaExceeded"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Account-level quota or billing exhaustion. Unlike `RateLimit`, waiting does not help. */
|
||||
export class QuotaExceeded extends Schema.TaggedErrorClass<QuotaExceeded>()("LLM.QuotaExceeded", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({
|
||||
_tag: Schema.tag("ContentPolicy"),
|
||||
message: Schema.String,
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
/** Provider refused the content for policy/safety reasons. */
|
||||
export class ContentPolicy extends Schema.TaggedErrorClass<ContentPolicy>()("LLM.ContentPolicy", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({
|
||||
_tag: Schema.tag("ProviderInternal"),
|
||||
message: Schema.String,
|
||||
status: Schema.Number,
|
||||
/**
|
||||
* The request exceeds the model's context window. Designated tag because
|
||||
* Core recovers from it structurally (compaction) rather than surfacing it.
|
||||
* Upgraded from `BadRequest` by the shared classifier in `provider-error.ts`.
|
||||
*/
|
||||
export class ContextOverflow extends Schema.TaggedErrorClass<ContextOverflow>()("LLM.ContextOverflow", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Provider-side failure (5xx, `overloaded_error`, internal exceptions). Retryable. */
|
||||
export class ServerError extends Schema.TaggedErrorClass<ServerError>()("LLM.ServerError", {
|
||||
...apiFailureFields,
|
||||
retryAfterMs: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
/** Any other deliberate API rejection that matches no designated tag (402, 405, 410, ...). */
|
||||
export class APIError extends Schema.TaggedErrorClass<APIError>()("LLM.APIError", {
|
||||
...apiFailureFields,
|
||||
}) {}
|
||||
|
||||
/** Communication failed: connect failure, reset, socket close, DNS. No API response involved. */
|
||||
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("LLM.ConnectionError", {
|
||||
message: Schema.String,
|
||||
kind: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
"LLM.Error.InvalidProviderOutput",
|
||||
)({
|
||||
_tag: Schema.tag("InvalidProviderOutput"),
|
||||
/** The request or stream read timed out before the provider answered. */
|
||||
export class TimeoutError extends Schema.TaggedErrorClass<TimeoutError>()("LLM.TimeoutError", {
|
||||
message: Schema.String,
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Transport succeeded but the content broke the protocol contract:
|
||||
* undecodable frames, premature EOF without a terminal `finish`, duplicate
|
||||
* terminals, or output after a terminal event.
|
||||
*/
|
||||
export class MalformedResponse extends Schema.TaggedErrorClass<MalformedResponse>()("LLM.MalformedResponse", {
|
||||
message: Schema.String,
|
||||
route: Schema.optional(Schema.String),
|
||||
raw: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({
|
||||
_tag: Schema.tag("UnknownProvider"),
|
||||
message: Schema.String,
|
||||
status: Schema.optional(Schema.Number),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
export const LLMErrorReason = Schema.Union([
|
||||
InvalidRequestReason,
|
||||
NoRouteReason,
|
||||
AuthenticationReason,
|
||||
RateLimitReason,
|
||||
QuotaExceededReason,
|
||||
ContentPolicyReason,
|
||||
ProviderInternalReason,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
UnknownProviderReason,
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason>
|
||||
|
||||
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: LLMErrorReason,
|
||||
/** Request construction failed locally: the selected model resolves to no executable route. */
|
||||
export class NoRoute extends Schema.TaggedErrorClass<NoRoute>()("LLM.NoRoute", {
|
||||
route: RouteID,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
override get message() {
|
||||
return `${this.module}.${this.method}: ${this.reason.message}`
|
||||
return `No LLM route for ${this.provider}/${this.model} using ${this.route}`
|
||||
}
|
||||
}
|
||||
|
||||
const members = [
|
||||
BadRequest,
|
||||
Authentication,
|
||||
PermissionDenied,
|
||||
NotFound,
|
||||
RateLimit,
|
||||
QuotaExceeded,
|
||||
ContentPolicy,
|
||||
ContextOverflow,
|
||||
ServerError,
|
||||
APIError,
|
||||
ConnectionError,
|
||||
TimeoutError,
|
||||
MalformedResponse,
|
||||
NoRoute,
|
||||
] as const
|
||||
|
||||
export const LLMErrorSchema = Schema.Union(members)
|
||||
|
||||
/**
|
||||
* Every failure of one LLM request. `LLMEvent` streams carry output only;
|
||||
* all failures — HTTP rejections, in-stream provider error events, transport
|
||||
* failures, and protocol-contract violations — exit through this union on
|
||||
* the stream's error channel.
|
||||
*/
|
||||
export type LLMError = typeof LLMErrorSchema.Type
|
||||
|
||||
export const isLLMError = (value: unknown): value is LLMError =>
|
||||
members.some((member) => value instanceof member)
|
||||
|
||||
/**
|
||||
* Failure type for tool execute handlers. Handlers must map their internal
|
||||
* errors to this shape; the runtime catches `ToolFailure`s and surfaces them
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelSchema } from "./options"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
/**
|
||||
* Token usage reported by an LLM provider.
|
||||
@@ -197,14 +196,6 @@ export const Finish = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.Finish" })
|
||||
export type Finish = Schema.Schema.Type<typeof Finish>
|
||||
|
||||
export const ProviderErrorEvent = Schema.Struct({
|
||||
type: Schema.tag("provider-error"),
|
||||
message: Schema.String,
|
||||
classification: Schema.optional(ProviderFailureClassification),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ProviderError" })
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
StepStart,
|
||||
TextStart,
|
||||
@@ -221,7 +212,6 @@ const llmEventTagged = Schema.Union([
|
||||
ToolError,
|
||||
StepFinish,
|
||||
Finish,
|
||||
ProviderErrorEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
type WithID<Event extends { readonly id: unknown }, ID> = Omit<Event, "type" | "id"> & { readonly id: ID | string }
|
||||
@@ -271,7 +261,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
...input,
|
||||
usage: input.usage === undefined ? undefined : Usage.from(input.usage),
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
@@ -288,7 +277,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
stepFinish: llmEventTagged.guards["step-finish"],
|
||||
finish: llmEventTagged.guards.finish,
|
||||
providerError: llmEventTagged.guards["provider-error"],
|
||||
},
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
@@ -374,13 +362,6 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
finishReason: event.reason,
|
||||
}
|
||||
}
|
||||
if (LLMEvent.is.providerError(event)) {
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
finishReason: state.finishReason ?? "error",
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
events,
|
||||
@@ -589,7 +570,7 @@ export namespace LLMResponse {
|
||||
/** Purely fold one provider-neutral event into the attempt assembly state. */
|
||||
export const reduce = reduceResponseState
|
||||
|
||||
/** Return a completed response only after a terminal finish or provider error. */
|
||||
/** Return a completed response only after a terminal finish event. */
|
||||
export const complete = (state: State): LLMResponse | undefined =>
|
||||
state.finishReason === undefined
|
||||
? undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, LLMError } from "../src"
|
||||
import { LLM, isLLMError, type LLMError } from "../src"
|
||||
import { LLMClient, RequestExecutor } from "../src/route"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
@@ -59,12 +59,12 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
|
||||
)
|
||||
|
||||
const expectLLMError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
if (!(error instanceof LLMError)) throw new Error("expected LLMError")
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
if (!isLLMError(error)) throw new Error("expected LLMError")
|
||||
return error
|
||||
}
|
||||
|
||||
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
const errorHttp = (error: LLMError) => ("http" in error ? error.http : undefined)
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
@@ -73,7 +73,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(error).toMatchObject({ _tag: "LLM.ContextOverflow" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -91,8 +91,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
@@ -102,8 +101,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
@@ -114,24 +112,22 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error).toMatchObject({
|
||||
reason: {
|
||||
_tag: "RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "<redacted>",
|
||||
},
|
||||
_tag: "LLM.RateLimit",
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-request-id": "req_123",
|
||||
"x-api-key": "<redacted>",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -169,8 +165,8 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit" })
|
||||
expect(error._tag === "LLM.RateLimit" ? error.rateLimit : undefined).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "500", tokens: "30000" },
|
||||
remaining: { requests: "499", tokens: "29900" },
|
||||
@@ -202,7 +198,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError" })
|
||||
expect(errorHttp(error)?.rateLimit).toEqual({
|
||||
retryAfterMs: 0,
|
||||
limit: { requests: "100", "input-tokens": "10000" },
|
||||
@@ -245,12 +241,12 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", status: 503 })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks 504 and 529 status responses as provider-internal", () =>
|
||||
it.effect("marks 504 and 529 status responses as server errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const failWith = (status: number) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -258,7 +254,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", status })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
@@ -281,7 +277,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(error).toMatchObject({ _tag: "LLM.Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||
}).pipe(
|
||||
@@ -360,7 +356,7 @@ describe("RequestExecutor", () => {
|
||||
)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -149,8 +149,8 @@ describe("request option precedence", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.BadRequest",
|
||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError, Message, ToolCallPart } from "../../src"
|
||||
import { isLLMError, LLM, Message, ToolCallPart } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import { weatherToolName } from "../recorded-scenarios"
|
||||
@@ -22,6 +22,9 @@ const malformedToolOrderRequest = LLM.request({
|
||||
Message.user("Use that result to answer briefly."),
|
||||
],
|
||||
tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }],
|
||||
// The cassette predates the `cache: "auto"` default; pin the policy off so
|
||||
// the replayed request matches the recorded wire shape.
|
||||
cache: "none",
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
@@ -33,13 +36,17 @@ const recorded = recordedTests({
|
||||
})
|
||||
|
||||
describe("Anthropic Messages sad-path recorded", () => {
|
||||
recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
||||
recorded.effect.with(
|
||||
"rejects malformed assistant tool order",
|
||||
// The cassette predates a test rename; keep replaying the existing recording.
|
||||
{ id: "rejects-malformed-assistant-tool-order-without-patch", tags: ["tool", "sad-path"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||
@@ -484,23 +484,25 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
||||
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
// Prefix the error type so consumers can distinguish overloads, rate
|
||||
// limits, and quota errors without parsing the message string.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error: Overloaded" }])
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error: Overloaded" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies prompt-too-long provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -509,35 +511,35 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "invalid_request_error: prompt is too long: 210000 tokens",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error type when no message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "overloaded_error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "overloaded_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when error payload is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "Anthropic Messages stream error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Anthropic Messages stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -553,8 +555,8 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { CacheHint, isLLMError, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
@@ -355,33 +355,31 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits provider-error for throttlingException", () =>
|
||||
it.effect("fails the stream for throttlingException", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["throttlingException", { message: "Slow down" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
|
||||
|
||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
||||
type: "provider-error",
|
||||
message: "Slow down",
|
||||
})
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "Slow down" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies input-too-long validation exceptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
const error = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "provider-error")).toEqual({
|
||||
type: "provider-error",
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "Input is too long for requested model",
|
||||
classification: "context-overflow",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { isLLMError, LLM, Message, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
@@ -560,8 +560,8 @@ describe("Gemini route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse" })
|
||||
expect(error.message).toContain("Invalid google/gemini stream event")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { isLLMError, LLM, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -662,8 +662,8 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { isLLMError, LLM, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -1368,37 +1368,41 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits provider-error events for mid-stream provider errors", () =>
|
||||
it.effect("fails the stream for mid-stream provider errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
// Prefix the code so consumers see the failure mode, not just the
|
||||
// sometimes-generic provider message. The bare message alone meant
|
||||
// production errors like rate limits were indistinguishable from
|
||||
// unrelated stream failures.
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.RateLimit", message: "rate_limit_exceeded: Slow down" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error code when no message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error code when message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "internal_error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1408,7 +1412,7 @@ describe("OpenAI Responses route", () => {
|
||||
// "OpenAI Responses response failed" string, hiding the real cause.
|
||||
it.effect("surfaces response.failed details from response.error", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1420,15 +1424,16 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.ServerError", message: "server_error: Upstream model unavailable" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1437,9 +1442,10 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest", message: "invalid_prompt" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1450,7 +1456,7 @@ describe("OpenAI Responses route", () => {
|
||||
// when they bubble up an HTTP error as an SSE `error` event. Honour
|
||||
// both shapes so the user still sees the underlying cause instead
|
||||
// of the catch-all string.
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1459,21 +1465,19 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces error event details nested under error", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1488,21 +1492,19 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{
|
||||
type: "provider-error",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
classification: "context-overflow",
|
||||
},
|
||||
])
|
||||
expect(error).toMatchObject({
|
||||
_tag: "LLM.ContextOverflow",
|
||||
message: "context_length_exceeded: prompt too long",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable fields in spec-compliant error events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
@@ -1514,39 +1516,43 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "Something went wrong" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "Something went wrong" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
|
||||
expect(error).toMatchObject({ _tag: "LLM.APIError", message: "OpenAI Responses response failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1562,8 +1568,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
expect(error).toMatchObject({ _tag: "LLM.BadRequest" })
|
||||
expect(error.message).toContain("HTTP 400")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLMError } from "../src/schema"
|
||||
import { isLLMError } from "../src/schema"
|
||||
import { ToolStream } from "../src/protocols/utils/tool-stream"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -40,8 +40,9 @@ describe("ToolStream", () => {
|
||||
Effect.gen(function* () {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
|
||||
expect(isLLMError(error)).toBe(true)
|
||||
if (ToolStream.isError(error))
|
||||
expect(error).toMatchObject({ _tag: "LLM.MalformedResponse", message: "missing tool" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -418,9 +418,6 @@ const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
case "provider-error":
|
||||
throw new Error(value.message)
|
||||
|
||||
case "step-start":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
yield* session.updatePart({
|
||||
|
||||
@@ -136,14 +136,14 @@ test("theme swaps restyle active reasoning without resetting the stream", async
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: previousSyntax,
|
||||
syntax: previousSyntax,
|
||||
},
|
||||
}
|
||||
const next = {
|
||||
...RUN_THEME_FALLBACK,
|
||||
block: {
|
||||
...RUN_THEME_FALLBACK.block,
|
||||
subtleSyntax: nextSyntax,
|
||||
syntax: nextSyntax,
|
||||
},
|
||||
}
|
||||
const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
|
||||
|
||||
@@ -67,9 +67,7 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
|
||||
try {
|
||||
expect(theme.block.syntax).toBeDefined()
|
||||
expect(theme.block.subtleSyntax).toBeDefined()
|
||||
expect([...theme.block.syntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expect([...theme.block.subtleSyntax!.getAllStyles()].length).toBeGreaterThan(0)
|
||||
expectIndexed(theme.splash.left)
|
||||
expectIndexed(theme.splash.right)
|
||||
expectIndexed(theme.splash.leftShadow)
|
||||
@@ -82,7 +80,6 @@ test("returns syntax styles and indexed splash colors", async () => {
|
||||
expect(expectRgba(theme.footer.statusAccent).toInts()).not.toEqual(expectRgba(theme.footer.status).toInts())
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -103,7 +100,6 @@ test("keeps footer surfaces exact while scrollback stays palette matched", async
|
||||
expectIndexed(theme.block.warning)
|
||||
} finally {
|
||||
theme.block.syntax?.destroy()
|
||||
theme.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,9 +115,7 @@ test("uses refreshed background brightness when cached renderer mode is stale",
|
||||
expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
|
||||
} finally {
|
||||
stale.block.syntax?.destroy()
|
||||
stale.block.subtleSyntax?.destroy()
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -138,9 +132,7 @@ test("keeps renderer mode when refreshed default background is unavailable", asy
|
||||
expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
|
||||
} finally {
|
||||
light.block.syntax?.destroy()
|
||||
light.block.subtleSyntax?.destroy()
|
||||
dark.block.syntax?.destroy()
|
||||
dark.block.subtleSyntax?.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -219,8 +219,7 @@ const fragmentFailureLLM = Layer.succeed(
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-1", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-1" }),
|
||||
LLMEvent.textDelta({ id: "text-1", text: "partial" }),
|
||||
LLMEvent.providerError({ message: "provider boom" }),
|
||||
),
|
||||
).pipe(Stream.concat(Stream.fail(new Error("provider boom")))),
|
||||
}),
|
||||
)
|
||||
const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
|
||||
|
||||
@@ -358,6 +358,7 @@ export type TuiTheme = {
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
export type TuiKV = {
|
||||
get: <Value = unknown>(key: string, fallback?: Value) => Value
|
||||
set: (key: string, value: unknown) => void
|
||||
@@ -434,6 +435,7 @@ type TuiConfigView = {
|
||||
sidebar?: "auto" | "hide"
|
||||
scrollbar?: boolean
|
||||
thinking?: "show" | "hide"
|
||||
markdown?: "source" | "rendered"
|
||||
grouping?: "auto" | "none"
|
||||
}
|
||||
hints?: { tips?: boolean; onboarding?: boolean }
|
||||
@@ -628,6 +630,7 @@ export type TuiPluginApi = {
|
||||
dialog: TuiDialogStack
|
||||
}
|
||||
readonly tuiConfig: Frozen<TuiConfigView>
|
||||
/** @deprecated Persistent TUI KV storage is not supported in V2. */
|
||||
kv: TuiKV
|
||||
state: TuiState
|
||||
theme: TuiTheme
|
||||
|
||||
@@ -19,12 +19,10 @@
|
||||
"./context/args": "./src/context/args.tsx",
|
||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||
"./context/exit": "./src/context/exit.tsx",
|
||||
"./context/kv": "./src/context/kv.tsx",
|
||||
"./context/log": "./src/context/log.tsx",
|
||||
"./context/project": "./src/context/project.tsx",
|
||||
"./context/runtime": "./src/context/runtime.tsx",
|
||||
"./context/sdk": "./src/context/sdk.tsx",
|
||||
"./context/sync": "./src/context/sync.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
|
||||
+101
-115
@@ -44,7 +44,6 @@ import { useEvent } from "./context/event"
|
||||
import { SDKProvider, useSDK } from "./context/sdk"
|
||||
import { StartupLoading } from "./component/startup-loading"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { SyncProvider, useSync } from "./context/sync"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
import { LocationProvider } from "./context/location"
|
||||
import { LocalProvider, useLocal } from "./context/local"
|
||||
@@ -71,7 +70,6 @@ import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { DialogConfirm } from "./ui/dialog-confirm"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
@@ -181,23 +179,6 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function isVersionGreater(left: string, right: string) {
|
||||
const parse = (value: string) => {
|
||||
const [core, prerelease] = value.replace(/^v/, "").split("-", 2)
|
||||
return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease }
|
||||
}
|
||||
const a = parse(left)
|
||||
const b = parse(right)
|
||||
for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) {
|
||||
const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0)
|
||||
if (difference) return difference > 0
|
||||
}
|
||||
if (a.prerelease === b.prerelease) return false
|
||||
if (!a.prerelease) return true
|
||||
if (!b.prerelease) return false
|
||||
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
|
||||
}
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const log = input.log ?? (() => {})
|
||||
const global = yield* Global.Service
|
||||
@@ -343,67 +324,63 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
client={createOpencodeClient({ ...options, directory })}
|
||||
api={api}
|
||||
reconnect={reconnect}
|
||||
reload={input.server.reload}
|
||||
>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<LocationProvider>
|
||||
<App
|
||||
pluginHost={input.pluginHost}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</LocationProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
client={createOpencodeClient({ ...options, directory })}
|
||||
api={api}
|
||||
reconnect={reconnect}
|
||||
reload={input.server.reload}
|
||||
>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<LocationProvider>
|
||||
<App
|
||||
pluginHost={input.pluginHost}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</LocationProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
@@ -440,17 +417,16 @@ function App(props: {
|
||||
}) {
|
||||
const log = useLog({ component: "app" })
|
||||
const startup = useTuiStartup()
|
||||
const config = useConfig().data
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
@@ -459,7 +435,7 @@ function App(props: {
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config, kv })
|
||||
const attention = createTuiAttention({ renderer, config, update: configState.update })
|
||||
const clipboard = useClipboard()
|
||||
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
@@ -496,12 +472,11 @@ function App(props: {
|
||||
tuiConfig: config,
|
||||
dialog,
|
||||
keymap,
|
||||
kv,
|
||||
route,
|
||||
routes: pluginRuntime.routes,
|
||||
event,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
data,
|
||||
theme: themeState,
|
||||
toast,
|
||||
@@ -549,8 +524,8 @@ function App(props: {
|
||||
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = kv.signal("terminal_title_enabled", true)
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = kv.signal("paste_summary_enabled", true)
|
||||
const terminalTitleEnabled = () => config.terminal?.title ?? true
|
||||
const pasteSummaryEnabled = () => config.prompt?.paste !== "full"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = !Flag.OPENCODE_DISABLE_MOUSE && config.mouse
|
||||
@@ -880,6 +855,7 @@ function App(props: {
|
||||
{
|
||||
name: "theme.switch_mode",
|
||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -889,6 +865,7 @@ function App(props: {
|
||||
{
|
||||
name: "theme.mode.lock",
|
||||
title: locked() ? "Unlock theme mode" : "Lock theme mode",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
if (locked()) unlock()
|
||||
else lock()
|
||||
@@ -956,41 +933,57 @@ function App(props: {
|
||||
name: "terminal.title.toggle",
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setTerminalTitleEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("terminal_title_enabled", next)
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
return next
|
||||
})
|
||||
const next = !terminalTitleEnabled()
|
||||
if (!next) renderer.setTerminalTitle("")
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.terminal = { ...draft.terminal, title: next }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.animations",
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
title: (config.animations ?? true) ? "Disable animations" : "Enable animations",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.animations = !(config.animations ?? true)
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.file_context",
|
||||
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
|
||||
title: (config.prompt?.editor ?? true) ? "Disable file context" : "Enable file context",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, editor: !(config.prompt?.editor ?? true) }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "app.toggle.diffwrap",
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
title: (config.diffs?.wrap ?? "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, wrap: (config.diffs?.wrap ?? "word") === "word" ? "none" : "word" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -998,12 +991,13 @@ function App(props: {
|
||||
name: "app.toggle.paste_summary",
|
||||
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setPasteSummaryEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("paste_summary_enabled", next)
|
||||
return next
|
||||
})
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.prompt = { ...draft.prompt, paste: pasteSummaryEnabled() ? "full" : "compact" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1101,21 +1095,13 @@ function App(props: {
|
||||
event.on("installation.update-available", async (evt) => {
|
||||
const version = evt.data.version
|
||||
|
||||
const skipped = kv.get("skipped_version")
|
||||
if (skipped && !isVersionGreater(version, skipped)) return
|
||||
|
||||
const choice = await DialogConfirm.show(
|
||||
dialog,
|
||||
`Update Available`,
|
||||
`A new release v${version} is available. Would you like to update now?`,
|
||||
"skip",
|
||||
"later",
|
||||
)
|
||||
|
||||
if (choice === false) {
|
||||
kv.set("skipped_version", version)
|
||||
return
|
||||
}
|
||||
|
||||
if (choice !== true) return
|
||||
|
||||
toast.show({
|
||||
|
||||
@@ -38,9 +38,8 @@ type TuiAttentionHost = TuiAttention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "opencode"
|
||||
const DEFAULT_TITLE = "OpenCode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const KV_SOUND_PACK = "attention_sound_pack"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
@@ -114,6 +113,8 @@ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
update?: Config.Interface["update"]
|
||||
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
@@ -134,8 +135,7 @@ export function createTuiAttention(input: {
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
return activePackID ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
@@ -234,7 +234,12 @@ export function createTuiAttention(input: {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
if (options?.persist)
|
||||
void input
|
||||
.update?.((draft) => {
|
||||
draft.attention = { ...draft.attention, sound_pack: pack.id }
|
||||
})
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createSignal, For, onMount, Show } from "solid-js"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Setting = {
|
||||
title: string
|
||||
category: string
|
||||
description: string
|
||||
detail?: string
|
||||
path: string[]
|
||||
default: unknown
|
||||
values?: readonly unknown[]
|
||||
@@ -26,18 +21,12 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Theme",
|
||||
category: "Appearance",
|
||||
description: "Interface color theme",
|
||||
detail:
|
||||
"Choose the color theme used throughout OpenCode. Custom themes discovered from your config directory appear here alongside the built-in themes.",
|
||||
path: ["theme", "name"],
|
||||
default: "opencode",
|
||||
},
|
||||
{
|
||||
title: "Color mode",
|
||||
category: "Appearance",
|
||||
description: "Terminal color preference",
|
||||
detail:
|
||||
"Choose how OpenCode selects its colors. System follows your terminal preference, while dark and light keep the interface in a fixed mode.",
|
||||
path: ["theme", "mode"],
|
||||
default: "system",
|
||||
values: ["system", "dark", "light"],
|
||||
@@ -45,7 +34,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Animations",
|
||||
category: "Appearance",
|
||||
description: "Interface motion",
|
||||
path: ["animations"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -54,7 +42,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Tips",
|
||||
category: "Appearance",
|
||||
description: "Home screen hints",
|
||||
path: ["hints", "tips"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -63,7 +50,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
description: "Getting-started guidance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -72,7 +58,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
description: "Session sidebar visibility",
|
||||
path: ["session", "sidebar"],
|
||||
default: "auto",
|
||||
values: ["hide", "auto"],
|
||||
@@ -80,7 +65,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scrollbar",
|
||||
category: "Session",
|
||||
description: "Transcript scrollbar",
|
||||
path: ["session", "scrollbar"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -89,15 +73,20 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
description: "Model reasoning by default",
|
||||
path: ["session", "thinking"],
|
||||
default: "hide",
|
||||
values: ["hide", "show"],
|
||||
},
|
||||
{
|
||||
title: "Markdown",
|
||||
category: "Session",
|
||||
path: ["session", "markdown"],
|
||||
default: "rendered",
|
||||
values: ["source", "rendered"],
|
||||
},
|
||||
{
|
||||
title: "Grouping",
|
||||
category: "Session",
|
||||
description: "Related transcript items",
|
||||
path: ["session", "grouping"],
|
||||
default: "auto",
|
||||
values: ["none", "auto"],
|
||||
@@ -105,7 +94,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
description: "Diff presentation",
|
||||
path: ["diffs", "view"],
|
||||
default: "auto",
|
||||
values: ["auto", "split", "unified"],
|
||||
@@ -113,7 +101,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Wrapping",
|
||||
category: "Diffs",
|
||||
description: "Long diff lines",
|
||||
path: ["diffs", "wrap"],
|
||||
default: "word",
|
||||
values: ["none", "word"],
|
||||
@@ -121,7 +108,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "File tree",
|
||||
category: "Diffs",
|
||||
description: "Diff file navigation",
|
||||
path: ["diffs", "tree"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -130,7 +116,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Single patch",
|
||||
category: "Diffs",
|
||||
description: "Only the selected patch",
|
||||
path: ["diffs", "single"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -139,7 +124,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Scroll speed",
|
||||
category: "Input",
|
||||
description: "Distance per input tick",
|
||||
path: ["scroll", "speed"],
|
||||
default: 3,
|
||||
step: 0.25,
|
||||
@@ -150,7 +134,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Acceleration",
|
||||
category: "Input",
|
||||
description: "Repeated scrolling",
|
||||
path: ["scroll", "acceleration"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -159,7 +142,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Mouse",
|
||||
category: "Input",
|
||||
description: "Terminal mouse capture",
|
||||
path: ["mouse"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -168,7 +150,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Editor context",
|
||||
category: "Input",
|
||||
description: "Active selection in prompts",
|
||||
path: ["prompt", "editor"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -177,7 +158,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Large pastes",
|
||||
category: "Input",
|
||||
description: "Paste display style",
|
||||
path: ["prompt", "paste"],
|
||||
default: "compact",
|
||||
values: ["compact", "full"],
|
||||
@@ -185,7 +165,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Leader timeout",
|
||||
category: "Input",
|
||||
description: "Wait after leader key",
|
||||
path: ["leader", "timeout"],
|
||||
default: 2000,
|
||||
step: 250,
|
||||
@@ -196,7 +175,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Attention",
|
||||
category: "Alerts",
|
||||
description: "Alerts when input is needed",
|
||||
path: ["attention", "enabled"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
@@ -205,7 +183,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Notifications",
|
||||
category: "Alerts",
|
||||
description: "System notifications",
|
||||
path: ["attention", "notifications"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -214,7 +191,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Sounds",
|
||||
category: "Alerts",
|
||||
description: "Attention sounds",
|
||||
path: ["attention", "sound"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -223,7 +199,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Volume",
|
||||
category: "Alerts",
|
||||
description: "Attention sound level",
|
||||
path: ["attention", "volume"],
|
||||
default: 0.4,
|
||||
step: 0.1,
|
||||
@@ -234,7 +209,6 @@ const settings: Setting[] = [
|
||||
{
|
||||
title: "Window title",
|
||||
category: "Terminal",
|
||||
description: "Update terminal title",
|
||||
path: ["terminal", "title"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
@@ -244,18 +218,10 @@ const settings: Setting[] = [
|
||||
|
||||
export function DialogConfig() {
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme } = themeState
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
onMount(() => {
|
||||
dialog.setSize("xlarge")
|
||||
dialog.setCentered(true)
|
||||
})
|
||||
|
||||
const value = (setting: Setting) => {
|
||||
const current = setting.path.reduce<unknown>((result, key) => {
|
||||
@@ -275,34 +241,18 @@ export function DialogConfig() {
|
||||
const index = setting.values?.indexOf(current)
|
||||
return index === undefined || index < 0 ? String(current) : (setting.labels?.[index] ?? String(current))
|
||||
}
|
||||
const rows = createMemo(() =>
|
||||
const options = createMemo(() =>
|
||||
settings.map((setting, index) => ({
|
||||
setting,
|
||||
index,
|
||||
heading: index === 0 || settings[index - 1].category !== setting.category,
|
||||
title: setting.title,
|
||||
category: setting.category,
|
||||
footer: display(setting),
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
const split = createMemo(() => dimensions().width >= 110)
|
||||
const height = createMemo(() => Math.max(8, Math.min(36, dimensions().height - 12)))
|
||||
|
||||
function move(direction: number) {
|
||||
const next = (selected() + direction + settings.length) % settings.length
|
||||
setSelected(next)
|
||||
queueMicrotask(() => {
|
||||
if (!scroll) return
|
||||
const row =
|
||||
next +
|
||||
settings.slice(0, next + 1).filter((setting, index) => {
|
||||
return index === 0 || settings[index - 1].category !== setting.category
|
||||
}).length
|
||||
if (row < scroll.scrollTop) scroll.scrollTo(row)
|
||||
if (row >= scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(row - scroll.viewport.height + 1)
|
||||
})
|
||||
}
|
||||
|
||||
async function change(direction: number) {
|
||||
async function change(direction: number, index = selected()) {
|
||||
if (saving()) return
|
||||
const setting = settings[selected()]
|
||||
const setting = settings[index]
|
||||
const current = value(setting)
|
||||
const choices = values(setting)
|
||||
const next = choices
|
||||
@@ -322,99 +272,27 @@ export function DialogConfig() {
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next setting",
|
||||
group: "Settings",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{ key: "left", desc: "Previous value", group: "Settings", cmd: () => void change(-1) },
|
||||
{ key: "right", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
{ key: "return", desc: "Next value", group: "Settings", cmd: () => void change(1) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" height={height() + 1}>
|
||||
<box width={split() ? "54%" : "100%"} paddingLeft={4} paddingRight={split() ? 3 : 4} paddingBottom={1}>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD} paddingBottom={1}>
|
||||
Settings
|
||||
</text>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<>
|
||||
<Show when={row.heading}>
|
||||
<box paddingTop={row.index === 0 ? 0 : 1}>
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{row.setting.category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" height={1}>
|
||||
<text
|
||||
width={25}
|
||||
fg={row.index === selected() ? theme.text : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{row.setting.title}
|
||||
</text>
|
||||
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? "‹ " : " "}</text>
|
||||
<text
|
||||
fg={row.index === selected() ? theme.primary : theme.textMuted}
|
||||
attributes={row.index === selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{display(row.setting)}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{row.index === selected() ? " ›" : " "}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<Show when={split()}>
|
||||
<box
|
||||
position="relative"
|
||||
top={-1}
|
||||
width="46%"
|
||||
height={height() + 2}
|
||||
paddingTop={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{settings[selected()].title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{settings[selected()].detail ?? settings[selected()].description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<DialogSelect
|
||||
title="Settings"
|
||||
options={options()}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(1, option.value)}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
bindings={[
|
||||
{
|
||||
key: "left",
|
||||
desc: "Previous value",
|
||||
group: "Settings",
|
||||
cmd: () => void change(-1),
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
desc: "Next value",
|
||||
group: "Settings",
|
||||
cmd: () => void change(1),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
{
|
||||
command: "model.dialog.provider",
|
||||
title: connected() ? "Connect integration" : "View all integrations",
|
||||
selection: "none",
|
||||
onTrigger() {
|
||||
dialog.replace(() => (
|
||||
<DialogIntegration
|
||||
@@ -154,6 +155,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
focusCurrent={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
selection: "none",
|
||||
onTrigger: () => void create(),
|
||||
},
|
||||
{
|
||||
@@ -360,6 +361,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
{
|
||||
command: "dialog.move_session.refresh",
|
||||
title: "refresh",
|
||||
selection: "none",
|
||||
onTrigger: () => void refetch(),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
{/* Headline */}
|
||||
<box flexDirection="column" alignItems="center" flexShrink={0}>
|
||||
<text attributes={TextAttributes.BOLD} fg={colors.text}>
|
||||
opencode crashed
|
||||
OpenCode crashed
|
||||
</text>
|
||||
<Show when={showSubtext()}>
|
||||
<text fg={colors.muted}>An unexpected error stopped the session.</text>
|
||||
@@ -192,7 +192,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
|
||||
? "Report copied — paste it into a new GitHub issue."
|
||||
: "Copy the report and open a GitHub issue to help us fix this."}
|
||||
</text>
|
||||
<text fg={colors.muted}>opencode {InstallationVersion}</text>
|
||||
<text fg={colors.muted}>OpenCode {InstallationVersion}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -211,7 +211,7 @@ function buildIssueURL(message: string, stack: string) {
|
||||
url.searchParams.set("terminal", describeTerminal())
|
||||
url.searchParams.set(
|
||||
"reproduce",
|
||||
"Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.",
|
||||
"Reported automatically from the OpenCode crash screen. If you can, describe what you were doing when it crashed.",
|
||||
)
|
||||
|
||||
// Budget the stack against the fully URL-encoded length (not the raw length) so
|
||||
@@ -220,7 +220,7 @@ function buildIssueURL(message: string, stack: string) {
|
||||
// so measuring url.toString() is both correct and safe on any input.
|
||||
const MAX_URL_LENGTH = 6000
|
||||
const marker = "\n... (truncated)"
|
||||
const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
|
||||
const head = `The OpenCode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n`
|
||||
const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```")
|
||||
|
||||
setBody(stack)
|
||||
|
||||
@@ -43,7 +43,6 @@ import { useDialog } from "../../ui/dialog"
|
||||
import { DialogIntegration } from "../dialog-integration"
|
||||
import { useConnected } from "../use-connected"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
@@ -177,11 +176,10 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
|
||||
const fileContextEnabled = createMemo(() => config.prompt?.editor ?? true)
|
||||
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
|
||||
const editorContext = createMemo(() => {
|
||||
const selection = fileContextEnabled() ? editor.selection() : undefined
|
||||
@@ -1191,7 +1189,7 @@ export function Prompt(props: PromptProps) {
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", true)
|
||||
config.prompt?.paste !== "full"
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
@@ -1495,7 +1493,7 @@ export function Prompt(props: PromptProps) {
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useConfig } from "../config"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { registerOpencodeSpinner } from "./register-spinner"
|
||||
@@ -11,10 +11,10 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
const kv = useKV()
|
||||
const config = useConfig().data
|
||||
const color = () => props.color ?? theme.textMuted
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
|
||||
@@ -118,6 +118,9 @@ export const Info = Schema.Struct({
|
||||
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
|
||||
description: "Group related transcript items automatically or render each item separately",
|
||||
}),
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { createEffect, createSignal, type Setter } from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import path from "path"
|
||||
import { useConfigOptional, type Config } from "../config"
|
||||
|
||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
name: "KV",
|
||||
init: (props: { config?: Config.Info }) => {
|
||||
const config = props.config ?? useConfigOptional()?.data
|
||||
const paths = useTuiPaths()
|
||||
void Global.Path.state
|
||||
const file = path.join(paths.state, "kv.json")
|
||||
const lock = `tui-kv:${file}`
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [store, setStore] = createStore<Record<string, any>>()
|
||||
// Queue same-process writes so rapid updates persist in order.
|
||||
let write = Promise.resolve()
|
||||
|
||||
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
|
||||
.then((x) => {
|
||||
const values: Record<string, any> = { ...x }
|
||||
Object.entries(configValues(config ?? {})).forEach(([key, value]) => {
|
||||
if (value === undefined) delete values[key]
|
||||
else values[key] = value
|
||||
})
|
||||
setStore(values)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to read KV state", { error })
|
||||
})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !config) return
|
||||
Object.entries(configValues(config)).forEach(([key, value]) => {
|
||||
if (value === undefined) setStore(key, undefined)
|
||||
else setStore(key, value)
|
||||
})
|
||||
})
|
||||
|
||||
const result = {
|
||||
get ready() {
|
||||
return ready()
|
||||
},
|
||||
get store() {
|
||||
return store
|
||||
},
|
||||
signal<T>(name: string, defaultValue: T) {
|
||||
if (store[name] === undefined) setStore(name, defaultValue)
|
||||
return [
|
||||
function () {
|
||||
return result.get(name)
|
||||
},
|
||||
function setter(next: Setter<T>) {
|
||||
result.set(name, next)
|
||||
},
|
||||
] as const
|
||||
},
|
||||
get(key: string, defaultValue?: any) {
|
||||
return store[key] ?? defaultValue
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
setStore(key, value)
|
||||
const snapshot = structuredClone(unwrap(store))
|
||||
write = write
|
||||
.then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)))
|
||||
.catch((error) => {
|
||||
console.error("Failed to write KV state", { error })
|
||||
})
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
function configValues(config: Config.Info) {
|
||||
const values: Record<string, any> = {}
|
||||
if (config.theme?.name !== undefined) values.theme = config.theme.name
|
||||
if (config.theme?.mode !== undefined) {
|
||||
values.theme_mode_lock = config.theme.mode === "system" ? undefined : config.theme.mode
|
||||
values.theme_mode = undefined
|
||||
}
|
||||
if (config.attention?.sound_pack !== undefined) values.attention_sound_pack = config.attention.sound_pack
|
||||
if (config.diffs?.wrap !== undefined) values.diff_wrap_mode = config.diffs.wrap
|
||||
if (config.diffs?.tree !== undefined) values.diff_viewer_show_file_tree = config.diffs.tree
|
||||
if (config.diffs?.single !== undefined) values.diff_viewer_single_patch = config.diffs.single
|
||||
if (config.diffs?.view !== undefined)
|
||||
values.diff_viewer_view = config.diffs.view === "auto" ? undefined : config.diffs.view
|
||||
if (config.terminal?.title !== undefined) values.terminal_title_enabled = config.terminal.title
|
||||
if (config.prompt?.editor !== undefined) values.file_context_enabled = config.prompt.editor
|
||||
if (config.prompt?.paste !== undefined) values.paste_summary_enabled = config.prompt.paste === "compact"
|
||||
if (config.session?.sidebar !== undefined) values.sidebar = config.session.sidebar
|
||||
if (config.session?.scrollbar !== undefined) values.scrollbar_visible = config.session.scrollbar
|
||||
if (config.session?.thinking !== undefined) values.thinking_mode = config.session.thinking
|
||||
if (config.session?.grouping !== undefined) values.exploration_grouping = config.session.grouping === "auto"
|
||||
if (config.hints?.tips !== undefined) values.tips_hidden = !config.hints.tips
|
||||
if (config.hints?.onboarding !== undefined) values.dismissed_getting_started = !config.hints.onboarding
|
||||
if (config.animations !== undefined) values.animations_enabled = config.animations
|
||||
return values
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
FormatterStatus,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Provider,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
FileDiffInfo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useProject } from "./project"
|
||||
|
||||
export const {
|
||||
context: SyncContext,
|
||||
use: useSync,
|
||||
provider: SyncProvider,
|
||||
} = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const project = useProject()
|
||||
const [store, setStore] = createStore<{
|
||||
status: "loading" | "partial" | "complete"
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
question: Record<string, QuestionRequest[]>
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_diff: Record<string, FileDiffInfo[]>
|
||||
message: Record<string, Message[]>
|
||||
part: Record<string, Part[]>
|
||||
lsp: LspStatus[]
|
||||
mcp: Record<string, McpStatus>
|
||||
mcp_resource: Record<string, McpResource>
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
}>({
|
||||
status: "complete",
|
||||
provider: [],
|
||||
agent: [],
|
||||
command: [],
|
||||
permission: {},
|
||||
question: {},
|
||||
config: {},
|
||||
session: [],
|
||||
session_diff: {},
|
||||
message: {},
|
||||
part: {},
|
||||
lsp: [],
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
get status() {
|
||||
return store.status
|
||||
},
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
session: {
|
||||
get(_sessionID: string) {
|
||||
return undefined as Session | undefined
|
||||
},
|
||||
query() {
|
||||
return {} as { scope?: "project"; path?: string }
|
||||
},
|
||||
async refresh() {},
|
||||
status(_sessionID: string) {
|
||||
return "idle" as const
|
||||
},
|
||||
async sync(_sessionID: string) {},
|
||||
},
|
||||
async bootstrap(_input: { fatal?: boolean } = {}) {},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSubtleSyntax,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useKV } from "./kv"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
@@ -64,7 +62,6 @@ export {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSubtleSyntax,
|
||||
generateSyntax,
|
||||
generateSystem,
|
||||
hasTheme,
|
||||
@@ -76,7 +73,6 @@ export {
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
type SyntaxStyleOverrides,
|
||||
} from "../theme"
|
||||
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
@@ -103,8 +99,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
|
||||
const renderer = useRenderer()
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const themes = props.source ?? themeSource
|
||||
const pick = (value: unknown) => {
|
||||
if (value === "dark" || value === "light") return value
|
||||
@@ -113,12 +109,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const lock = pick(kv.get("theme_mode_lock"))
|
||||
const lock = pick(config.theme?.mode)
|
||||
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
|
||||
if (!lock && pick(kv.get("theme_mode")) !== undefined) kv.set("theme_mode", undefined)
|
||||
draft.mode = mode
|
||||
draft.lock = lock
|
||||
const active = config.theme?.name ?? kv.get("theme", "opencode")
|
||||
const active = config.theme?.name ?? "opencode"
|
||||
draft.active = typeof active === "string" ? active : "opencode"
|
||||
draft.ready = false
|
||||
}),
|
||||
@@ -132,10 +127,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
createEffect(() => {
|
||||
const mode = config.theme?.mode
|
||||
if (mode === "dark" || mode === "light") {
|
||||
pin(mode)
|
||||
pin(mode, false)
|
||||
return
|
||||
}
|
||||
if (mode === "system" && store.lock !== undefined) free()
|
||||
if (mode === "system" && store.lock !== undefined) free(false)
|
||||
})
|
||||
|
||||
function syncCustomThemes() {
|
||||
@@ -209,23 +204,31 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function apply(mode: "dark" | "light") {
|
||||
if (store.lock !== undefined) kv.set("theme_mode", mode)
|
||||
if (store.mode === mode) return
|
||||
setStore("mode", mode)
|
||||
refreshSystemTheme(mode)
|
||||
}
|
||||
|
||||
function pin(mode: "dark" | "light" = store.mode) {
|
||||
function pin(mode: "dark" | "light" = store.mode, persist = true) {
|
||||
setStore("lock", mode)
|
||||
kv.set("theme_mode_lock", mode)
|
||||
apply(mode)
|
||||
if (!persist) return
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.theme = { ...draft.theme, mode }
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function free() {
|
||||
function free(persist = true) {
|
||||
setStore("lock", undefined)
|
||||
kv.set("theme_mode_lock", undefined)
|
||||
kv.set("theme_mode", undefined)
|
||||
refreshSystemTheme(renderer.themeMode ?? store.mode)
|
||||
if (!persist) return
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.theme = { ...draft.theme, mode: "system" }
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const handle = (mode: "dark" | "light") => {
|
||||
@@ -265,20 +268,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
const values = createMemo(() => {
|
||||
const active = store.themes[store.active]
|
||||
if (active) return resolveTheme(active, store.mode)
|
||||
|
||||
const saved = kv.get("theme")
|
||||
if (typeof saved === "string") {
|
||||
const theme = store.themes[saved]
|
||||
if (theme) return resolveTheme(theme, store.mode)
|
||||
}
|
||||
|
||||
return resolveTheme(store.themes.opencode, store.mode)
|
||||
})
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(values().background))
|
||||
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
|
||||
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
|
||||
|
||||
return {
|
||||
theme: new Proxy(values(), {
|
||||
@@ -293,7 +288,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
mode: () => store.mode,
|
||||
locked: () => store.lock !== undefined,
|
||||
lock: () => pin(store.mode),
|
||||
@@ -302,7 +296,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
kv.set("theme", theme)
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.theme = { ...draft.theme, name: theme }
|
||||
})
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
get ready() {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { createMemo, type Setter } from "solid-js"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export type ThinkingMode = "show" | "hide"
|
||||
|
||||
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
|
||||
@@ -16,52 +13,8 @@ export function reasoningSummary(text: string) {
|
||||
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
|
||||
}
|
||||
|
||||
export function isThinkingMode(value: unknown): value is ThinkingMode {
|
||||
return typeof value === "string" && (MODES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
// Cycle order matches the slash command: show → hide → show.
|
||||
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
|
||||
const idx = MODES.indexOf(current)
|
||||
return MODES[(idx + 1) % MODES.length] ?? "show"
|
||||
}
|
||||
|
||||
export function useThinkingMode() {
|
||||
const kv = useKV()
|
||||
// Capture pre-state before `kv.signal` seeds a default, so we can detect
|
||||
// first-time users with a legacy `thinking_visibility` boolean and migrate.
|
||||
// The KVProvider only renders children once kv.ready, so reads here are safe.
|
||||
const hadStored = kv.get("thinking_mode") !== undefined
|
||||
const legacy = kv.get("thinking_visibility")
|
||||
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
|
||||
|
||||
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
|
||||
// overload set; passing an updater fn through a property access loses the
|
||||
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
|
||||
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
|
||||
// an updater.
|
||||
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
|
||||
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
|
||||
else setStored(() => next)
|
||||
}
|
||||
|
||||
// Preserve previous experience for users who had explicitly toggled the
|
||||
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
|
||||
// get the new "hide" default (collapsed thinking).
|
||||
if (!hadStored) {
|
||||
if (legacy === true) set("show")
|
||||
else if (legacy === false) set("hide")
|
||||
}
|
||||
|
||||
if ((stored() as string) === "minimal") set("hide")
|
||||
|
||||
const mode = createMemo<ThinkingMode>(() => {
|
||||
const value = stored()
|
||||
return isThinkingMode(value) ? value : "hide"
|
||||
})
|
||||
|
||||
return {
|
||||
mode,
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Tips } from "./tips-view"
|
||||
import { useBindings } from "../../keymap"
|
||||
import { useData } from "../../context/data"
|
||||
import { hasConnectedProvider } from "../../util/connected-provider"
|
||||
import { useConfig } from "../../config"
|
||||
|
||||
const id = "internal:home-tips"
|
||||
|
||||
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
|
||||
const config = useConfig()
|
||||
useBindings(() => ({
|
||||
commands: [
|
||||
{
|
||||
@@ -16,8 +18,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
|
||||
title: props.hidden ? "Show tips" : "Hide tips",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
hidden: true,
|
||||
run() {
|
||||
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.hints = { ...draft.hints, tips: props.hidden }
|
||||
})
|
||||
.catch(() => {})
|
||||
props.api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -40,7 +47,8 @@ const tui: TuiPlugin = async (api) => {
|
||||
slots: {
|
||||
home_bottom() {
|
||||
const data = useData()
|
||||
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
||||
const config = useConfig().data
|
||||
const hidden = createMemo(() => !(config.hints?.tips ?? true))
|
||||
const first = createMemo(() => api.state.session.count() === 0)
|
||||
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
|
||||
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
||||
|
||||
@@ -4,18 +4,20 @@ import { createMemo, Show } from "solid-js"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useConfig } from "../../config"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
const paths = useTuiPaths()
|
||||
const config = useConfig()
|
||||
const theme = () => props.api.theme.current
|
||||
const has = createMemo(() =>
|
||||
props.api.state.provider.some(
|
||||
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
|
||||
),
|
||||
)
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const done = createMemo(() => !(config.data.hints?.onboarding ?? true))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const location = createMemo(() => {
|
||||
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
@@ -44,7 +46,16 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
<text fg={theme().text}>
|
||||
<b>Getting started</b>
|
||||
</text>
|
||||
<text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
|
||||
<text
|
||||
fg={theme().textMuted}
|
||||
onMouseDown={() =>
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.hints = { ...draft.hints, onboarding: false }
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
>
|
||||
✕
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
@@ -41,9 +42,6 @@ const MIN_SPLIT_WIDTH = 100
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||
const VCS_DIFF_CONTEXT_LINES = 12
|
||||
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
|
||||
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
|
||||
const KV_VIEW = "diff_viewer_view"
|
||||
type DiffMode = "working" | "branch" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
type DiffView = "split" | "unified"
|
||||
@@ -92,6 +90,7 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const sdk = useSDK()
|
||||
const config = useConfig()
|
||||
const themeState = useTheme()
|
||||
const theme = () => props.api.theme.current
|
||||
const params = () =>
|
||||
@@ -135,11 +134,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
})
|
||||
const files = createMemo(() => diff() ?? [])
|
||||
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
|
||||
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
|
||||
)
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
|
||||
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
|
||||
const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
|
||||
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
@@ -148,7 +145,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
if (props.api.tuiConfig.diffs?.view === "split") return "split"
|
||||
return splitAvailable() ? "split" : "unified"
|
||||
})
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(config.data.diffs?.view))
|
||||
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
|
||||
const fileTree = createMemo(() => buildFileTree(files()))
|
||||
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
|
||||
@@ -623,23 +620,33 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
name: "diff.toggle_file_tree",
|
||||
title: "Toggle diff viewer file tree",
|
||||
category: "VCS",
|
||||
hidden: true,
|
||||
run() {
|
||||
const next = !fileTreeEnabled()
|
||||
if (!next) setFocus("patches")
|
||||
setFileTreeEnabled(next)
|
||||
props.api.kv.set(KV_SHOW_FILE_TREE, next)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, tree: next }
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.single_patch",
|
||||
title: "Toggle single patch view",
|
||||
category: "VCS",
|
||||
hidden: true,
|
||||
run() {
|
||||
setSelectedHunk(undefined)
|
||||
if (!singlePatch()) {
|
||||
ensureHighlightedPatchFile()
|
||||
setSinglePatch(true)
|
||||
props.api.kv.set(KV_SINGLE_PATCH, true)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, single: true }
|
||||
})
|
||||
.catch(() => {})
|
||||
scrollSinglePatchToTop()
|
||||
return
|
||||
}
|
||||
@@ -653,7 +660,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
)
|
||||
if (fileIndex !== undefined) selectPatchFile(fileIndex)
|
||||
setSinglePatch(false)
|
||||
props.api.kv.set(KV_SINGLE_PATCH, false)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, single: false }
|
||||
})
|
||||
.catch(() => {})
|
||||
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
|
||||
},
|
||||
},
|
||||
@@ -669,12 +680,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
name: "diff.toggle_view",
|
||||
title: "Toggle diff viewer split or unified view",
|
||||
category: "VCS",
|
||||
hidden: true,
|
||||
run() {
|
||||
if (!splitAvailable()) return
|
||||
setSelectedHunk(undefined)
|
||||
const next = view() === "split" ? "unified" : "split"
|
||||
setViewOverride(next)
|
||||
props.api.kv.set(KV_VIEW, next)
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, view: next }
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -217,6 +217,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
{
|
||||
title: "install",
|
||||
command: "dialog.plugins.install",
|
||||
selection: "none",
|
||||
hidden: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
|
||||
@@ -22,8 +22,6 @@ const command = {
|
||||
} as const
|
||||
|
||||
const LAYER_PRIORITY = 900
|
||||
const KV_LAYOUT = "which_key_layout"
|
||||
const KV_PENDING_PREVIEW = "which_key_pending_preview"
|
||||
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
|
||||
const scrollCommands = [
|
||||
command.scrollUp,
|
||||
@@ -531,8 +529,8 @@ function WhichKeyPanel(props: {
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const [pinned, setPinned] = createSignal(false)
|
||||
const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
|
||||
const [mode, setMode] = createSignal(layout("dock"))
|
||||
const [pendingPreview, setPendingPreview] = createSignal(false)
|
||||
|
||||
api.keymap.registerLayer({
|
||||
priority: LAYER_PRIORITY,
|
||||
@@ -554,7 +552,6 @@ const tui: TuiPlugin = async (api) => {
|
||||
run() {
|
||||
setMode((value) => {
|
||||
const next = value === "dock" ? "overlay" : "dock"
|
||||
api.kv.set(KV_LAYOUT, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
@@ -566,7 +563,6 @@ const tui: TuiPlugin = async (api) => {
|
||||
category: "System",
|
||||
run() {
|
||||
setPendingPreview((value) => {
|
||||
api.kv.set(KV_PENDING_PREVIEW, !value)
|
||||
return !value
|
||||
})
|
||||
},
|
||||
|
||||
@@ -3,12 +3,11 @@ import type { Config } from "../config"
|
||||
import type { useEvent } from "../context/event"
|
||||
import type { useRoute } from "../context/route"
|
||||
import type { useSDK } from "../context/sdk"
|
||||
import type { useSync } from "../context/sync"
|
||||
import type { useData } from "../context/data"
|
||||
import type { useProject } from "../context/project"
|
||||
import type { useTheme } from "../context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
|
||||
import type { useOpencodeKeymap } from "../keymap"
|
||||
import type { useKV } from "../context/kv"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
@@ -26,12 +25,11 @@ type Input = {
|
||||
tuiConfig: Config.Resolved
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keymap: ReturnType<typeof useOpencodeKeymap>
|
||||
kv: ReturnType<typeof useKV>
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: PluginRoutes
|
||||
event: ReturnType<typeof useEvent>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
data: ReturnType<typeof useData>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
@@ -97,57 +95,51 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
get config() {
|
||||
return sync.data.config
|
||||
return {}
|
||||
},
|
||||
get provider() {
|
||||
return sync.data.provider
|
||||
return []
|
||||
},
|
||||
get path() {
|
||||
return sync.path
|
||||
return project.instance.path()
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
return {
|
||||
branch: sync.data.vcs.branch,
|
||||
default_branch: sync.data.vcs.default_branch,
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return data.session.list().length
|
||||
},
|
||||
get(sessionID) {
|
||||
return sync.session.get(sessionID)
|
||||
get(_sessionID) {
|
||||
return undefined
|
||||
},
|
||||
diff(sessionID) {
|
||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||
)
|
||||
diff(_sessionID) {
|
||||
return []
|
||||
},
|
||||
messages(sessionID) {
|
||||
return sync.data.message[sessionID] ?? []
|
||||
messages(_sessionID) {
|
||||
return []
|
||||
},
|
||||
status(sessionID) {
|
||||
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
||||
},
|
||||
permission(sessionID) {
|
||||
return sync.data.permission[sessionID] ?? []
|
||||
permission(_sessionID) {
|
||||
return []
|
||||
},
|
||||
question(sessionID) {
|
||||
return sync.data.question[sessionID] ?? []
|
||||
question(_sessionID) {
|
||||
return []
|
||||
},
|
||||
},
|
||||
part(messageID) {
|
||||
return sync.data.part[messageID] ?? []
|
||||
part(_messageID) {
|
||||
return []
|
||||
},
|
||||
lsp() {
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
return []
|
||||
},
|
||||
mcp() {
|
||||
return (data.location.mcp.server.list() ?? [])
|
||||
@@ -292,17 +284,14 @@ export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycl
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(key, fallback) {
|
||||
return input.kv.get(key, fallback)
|
||||
},
|
||||
set(key, value) {
|
||||
input.kv.set(key, value)
|
||||
},
|
||||
get ready() {
|
||||
return input.kv.ready
|
||||
get(_key, fallback) {
|
||||
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
|
||||
return fallback
|
||||
},
|
||||
set() {},
|
||||
ready: true,
|
||||
},
|
||||
state: stateApi(input.sync, input.data),
|
||||
state: stateApi(input.project, input.data),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useData } from "../../context/data"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../context/theme"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
@@ -54,7 +54,6 @@ import { filetype } from "../../util/filetype"
|
||||
import parsers from "../../parsers-config"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Toast, useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv.tsx"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
@@ -66,7 +65,7 @@ import { DialogExportResult } from "../../ui/dialog-export-result"
|
||||
import { sessionEpilogue } from "../../util/presentation"
|
||||
import { useConfig } from "../../config"
|
||||
import { useClipboard } from "../../context/clipboard"
|
||||
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
|
||||
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
@@ -122,6 +121,7 @@ const context = createContext<{
|
||||
sessionID: string
|
||||
thinkingMode: () => ThinkingMode
|
||||
showThinking: () => boolean
|
||||
markdownMode: () => "source" | "rendered"
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
@@ -147,8 +147,8 @@ export function Session() {
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const paths = useTuiPaths()
|
||||
const config = useConfig().data
|
||||
const kv = useKV()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
const { theme } = useTheme()
|
||||
const promptRef = usePromptRef()
|
||||
const session = createMemo(() => data.session.get(route.sessionID))
|
||||
@@ -194,15 +194,14 @@ export function Session() {
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
|
||||
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const thinking = useThinkingMode()
|
||||
const thinkingMode = thinking.mode
|
||||
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
|
||||
const showThinking = createMemo(() => true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [groupExploration, setGroupExploration] = kv.signal("exploration_grouping", true)
|
||||
const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
|
||||
const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
|
||||
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
|
||||
const groupExploration = createMemo(() => config.session?.grouping !== "none")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -462,7 +461,11 @@ export function Session() {
|
||||
run: () => {
|
||||
batch(() => {
|
||||
const isVisible = sidebarVisible()
|
||||
setSidebar(() => (isVisible ? "hide" : "auto"))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, sidebar: isVisible ? "hide" : "auto" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!isVisible)
|
||||
})
|
||||
dialog.clear()
|
||||
@@ -476,12 +479,17 @@ export function Session() {
|
||||
})(),
|
||||
value: "session.toggle.thinking",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
slash: {
|
||||
name: "thinking",
|
||||
aliases: ["toggle-thinking"],
|
||||
},
|
||||
run: () => {
|
||||
thinking.set(nextThinkingMode(thinkingMode()))
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, thinking: nextThinkingMode(thinkingMode()) }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -489,8 +497,13 @@ export function Session() {
|
||||
title: "Toggle session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setShowScrollbar((prev) => !prev)
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, scrollbar: !showScrollbar() }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -498,8 +511,13 @@ export function Session() {
|
||||
title: groupExploration() ? "Show tool calls individually" : "Group related tool calls",
|
||||
value: "session.toggle.exploration_grouping",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
setGroupExploration((prev) => !prev)
|
||||
void configState
|
||||
.update((draft) => {
|
||||
draft.session = { ...draft.session, grouping: groupExploration() ? "none" : "auto" }
|
||||
})
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -855,6 +873,7 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
thinkingMode,
|
||||
showThinking,
|
||||
markdownMode,
|
||||
groupExploration,
|
||||
diffWrapMode,
|
||||
models,
|
||||
@@ -1287,7 +1306,6 @@ function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { typ
|
||||
|
||||
function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type: "compaction" }> }) {
|
||||
const ctx = use()
|
||||
const kv = useKV()
|
||||
const { theme, syntax } = useTheme()
|
||||
const status = () => props.message.status
|
||||
const text = () => (props.message.status === "failed" ? props.message.error.message : props.message.summary)
|
||||
@@ -1300,7 +1318,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯</text>}>
|
||||
<Show when={ctx.config.animations ?? true} fallback={<text fg={color()}>⋯</text>}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1320,7 +1338,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
internalBlockMode="top-level"
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={false}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -1698,7 +1716,7 @@ function ReasoningPart(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
message: SessionMessageAssistant
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const { theme, syntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
@@ -1718,8 +1736,6 @@ function ReasoningPart(props: {
|
||||
return end === undefined ? 0 : Math.max(0, end - start)
|
||||
})
|
||||
const summary = createMemo(() => reasoningSummary(content()))
|
||||
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
@@ -1745,7 +1761,7 @@ function ReasoningPart(props: {
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={summary().body}
|
||||
conceal={false}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
@@ -1811,7 +1827,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={false}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdownText}
|
||||
bg={theme.background}
|
||||
/>
|
||||
@@ -2380,10 +2396,6 @@ function Subagent(props: ToolProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function formatSubagentToolcalls(count: number) {
|
||||
return `${count} toolcall${count === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
|
||||
return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
|
||||
}
|
||||
@@ -2392,11 +2404,6 @@ export function formatSubagentRetry(attempt: number, message: string) {
|
||||
return `Retrying (attempt ${attempt}) · ${message}`
|
||||
}
|
||||
|
||||
export function formatCompletedSubagentDetail(toolcalls: number, duration: string) {
|
||||
if (toolcalls === 0) return duration
|
||||
return `${formatSubagentToolcalls(toolcalls)} · ${duration}`
|
||||
}
|
||||
|
||||
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
function executeCalls(value: unknown): ExecuteCall[] {
|
||||
|
||||
@@ -177,7 +177,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ export type Theme = {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
export type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
@@ -557,32 +556,6 @@ export function generateSyntax(theme: Theme) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
|
||||
if (rule.style.foreground) {
|
||||
const fg = rule.style.foreground
|
||||
return {
|
||||
...rule,
|
||||
style: {
|
||||
...rule.style,
|
||||
...override,
|
||||
foreground: RGBA.fromInts(
|
||||
Math.round(fg.r * 255),
|
||||
Math.round(fg.g * 255),
|
||||
Math.round(fg.b * 255),
|
||||
Math.round(theme.thinkingOpacity * 255),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getSyntaxRules(theme: Theme) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -36,14 +36,7 @@ export interface DialogSelectProps<T> {
|
||||
renderFilter?: boolean
|
||||
locked?: boolean
|
||||
preserveSelection?: boolean
|
||||
actions?: {
|
||||
command: string
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
hidden?: boolean
|
||||
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
actions?: DialogSelectAction<T>[]
|
||||
footerHints?: {
|
||||
title: string
|
||||
label: string
|
||||
@@ -51,8 +44,27 @@ export interface DialogSelectProps<T> {
|
||||
}[]
|
||||
bindings?: readonly Binding<Renderable, KeyEvent>[]
|
||||
current?: T
|
||||
focusCurrent?: boolean
|
||||
}
|
||||
|
||||
type DialogSelectActionBase<T> = {
|
||||
command: string
|
||||
title: string
|
||||
side?: "left" | "right"
|
||||
hidden?: boolean
|
||||
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||
}
|
||||
|
||||
type DialogSelectAction<T> =
|
||||
| (DialogSelectActionBase<T> & {
|
||||
selection?: "required"
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
})
|
||||
| (DialogSelectActionBase<T> & {
|
||||
selection: "none"
|
||||
onTrigger: () => void
|
||||
})
|
||||
|
||||
export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
titleView?: JSX.Element
|
||||
@@ -104,6 +116,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.current,
|
||||
(current) => {
|
||||
if (props.focusCurrent === false) return
|
||||
if (current) {
|
||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||
if (currentIndex >= 0) {
|
||||
@@ -220,7 +233,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
on(
|
||||
() => props.options,
|
||||
() => {
|
||||
if (!props.preserveSelection) return
|
||||
if (!props.preserveSelection) {
|
||||
const next = Math.min(store.selected, flat().length - 1)
|
||||
if (next >= 0 && next !== store.selected) setStore("selected", next)
|
||||
return
|
||||
}
|
||||
if (resetSelection && store.filter.length > 0) {
|
||||
const option = flat()[0]
|
||||
if (!option) return
|
||||
@@ -229,7 +246,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return
|
||||
}
|
||||
if (!selection) {
|
||||
if (props.current !== undefined) {
|
||||
if (props.focusCurrent !== false && props.current !== undefined) {
|
||||
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
|
||||
if (index >= 0) {
|
||||
setStore("selected", index)
|
||||
@@ -279,7 +296,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setTimeout(() => {
|
||||
if (filter.length > 0) {
|
||||
moveTo(0, true, false)
|
||||
} else if (current) {
|
||||
} else if (current && props.focusCurrent !== false) {
|
||||
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
|
||||
if (currentIndex >= 0) {
|
||||
moveTo(currentIndex, true)
|
||||
@@ -348,7 +365,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
setStore("input", "keyboard")
|
||||
const index = focusedAction()
|
||||
if (index !== undefined) {
|
||||
triggerAction(actionItems()[index])
|
||||
trigger(actionItems()[index])
|
||||
return
|
||||
}
|
||||
const option = selected()
|
||||
@@ -439,14 +456,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
name: item.command,
|
||||
title: item.title,
|
||||
category: "Dialog",
|
||||
run() {
|
||||
if (props.locked) return
|
||||
if (isActionDisabled(item)) return
|
||||
setStore("input", "keyboard")
|
||||
const option = selected()
|
||||
if (!option) return
|
||||
item.onTrigger(option)
|
||||
},
|
||||
run: () => trigger(item),
|
||||
})),
|
||||
],
|
||||
bindings: [
|
||||
@@ -502,10 +512,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
|
||||
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||
|
||||
function triggerAction(item: VisibleAction | undefined) {
|
||||
if (props.locked) return
|
||||
if (!item || !isActionItem(item) || isActionDisabled(item)) return
|
||||
function trigger(item: Action | undefined) {
|
||||
if (props.locked || !item || isActionDisabled(item)) return
|
||||
setStore("input", "keyboard")
|
||||
if (item.selection === "none") {
|
||||
item.onTrigger()
|
||||
return
|
||||
}
|
||||
const option = selected()
|
||||
if (!option) return
|
||||
item.onTrigger(option)
|
||||
@@ -516,7 +529,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
|
||||
function isActionDisabled(item: Action) {
|
||||
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
|
||||
const option = selected()
|
||||
if (item.selection !== "none" && !option) return true
|
||||
return typeof item.disabled === "function" ? item.disabled(option) : item.disabled
|
||||
}
|
||||
|
||||
function isActionFocused(item: VisibleAction) {
|
||||
@@ -543,7 +558,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseUp={() => triggerAction(item)}
|
||||
onMouseUp={() => trigger(item)}
|
||||
>
|
||||
<text
|
||||
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ArgsProvider } from "../../../../src/context/args"
|
||||
import { KVProvider, useKV } from "../../../../src/context/kv"
|
||||
import { ProjectProvider, useProject } from "../../../../src/context/project"
|
||||
import { SDKProvider } from "../../../../src/context/sdk"
|
||||
import { SyncProvider, useSync } from "../../../../src/context/sync"
|
||||
import { PermissionProvider } from "../../../../src/context/permission"
|
||||
import { ExitProvider } from "../../../../src/context/exit"
|
||||
import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../../fixture/tui-environment"
|
||||
export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk"
|
||||
|
||||
export async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
|
||||
|
||||
export async function mount(override?: FetchHandler, state?: string) {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(override, events)
|
||||
let sync!: ReturnType<typeof useSync>
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let kv!: ReturnType<typeof useKV>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
|
||||
onMount(() => {
|
||||
sync = ctx.sync
|
||||
project = ctx.project
|
||||
kv = ctx.kv
|
||||
done()
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={state ? { state } : undefined}>
|
||||
<ArgsProvider>
|
||||
<KVProvider>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<PermissionProvider>
|
||||
<ProjectProvider>
|
||||
<ExitProvider exit={() => {}}>
|
||||
<SyncProvider>
|
||||
<Probe />
|
||||
</SyncProvider>
|
||||
</ExitProvider>
|
||||
</ProjectProvider>
|
||||
</PermissionProvider>
|
||||
</SDKProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
await ready
|
||||
await wait(() => sync.status === "complete")
|
||||
return { app, emit: events.emit, kv, project, sync, session: calls.session }
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { mount } from "./sync-fixture"
|
||||
|
||||
test("legacy sync is an inert compatibility context", async () => {
|
||||
const { app, session, sync } = await mount()
|
||||
|
||||
try {
|
||||
expect(sync.status).toBe("complete")
|
||||
expect(sync.ready).toBe(true)
|
||||
expect(sync.data.session).toEqual([])
|
||||
expect(sync.data.message).toEqual({})
|
||||
expect(sync.data.provider).toEqual([])
|
||||
expect(sync.session.get("ses_test")).toBeUndefined()
|
||||
|
||||
await sync.bootstrap()
|
||||
await sync.session.refresh()
|
||||
await sync.session.sync("ses_test")
|
||||
|
||||
expect(session).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -1202,6 +1202,21 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
{ type: "compaction-queued", inputID: "message-compaction-later" },
|
||||
])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_text_ended",
|
||||
created: 2,
|
||||
type: "session.text.ended",
|
||||
durable: durable(sessionID, 5),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
ordinal: 0,
|
||||
text: "Active output",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "part"))
|
||||
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
created: 2,
|
||||
|
||||
@@ -26,12 +26,10 @@ async function mountPrompt(input: {
|
||||
}) {
|
||||
const state = path.join(input.root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const [
|
||||
{ DialogProvider },
|
||||
{ DialogPrompt },
|
||||
{ KVProvider },
|
||||
{ ThemeProvider },
|
||||
{ ConfigProvider },
|
||||
{ ToastProvider },
|
||||
@@ -39,7 +37,6 @@ async function mountPrompt(input: {
|
||||
] = await Promise.all([
|
||||
import("../../../src/ui/dialog"),
|
||||
import("../../../src/ui/dialog-prompt"),
|
||||
import("../../../src/context/kv"),
|
||||
import("../../../src/context/theme"),
|
||||
import("../../../src/config"),
|
||||
import("../../../src/ui/toast"),
|
||||
@@ -67,15 +64,13 @@ async function mountPrompt(input: {
|
||||
>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={resolvedConfig}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { InputRenderable } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import type { DialogSelectOption } from "../../../src/ui/dialog-select"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
async function renderSelect(
|
||||
root: string,
|
||||
options: DialogSelectOption<string>[],
|
||||
onGlobal: () => void,
|
||||
onRow: (option: DialogSelectOption<string>) => void,
|
||||
) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const config = createTuiResolvedConfig()
|
||||
const [
|
||||
{ ConfigProvider },
|
||||
{ ThemeProvider },
|
||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
||||
{ DialogProvider },
|
||||
{ DialogSelect },
|
||||
{ ToastProvider },
|
||||
] = await Promise.all([
|
||||
import("../../../src/config"),
|
||||
import("../../../src/context/theme"),
|
||||
import("../../../src/keymap"),
|
||||
import("../../../src/ui/dialog"),
|
||||
import("../../../src/ui/dialog-select"),
|
||||
import("../../../src/ui/toast"),
|
||||
])
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||
onCleanup(off)
|
||||
|
||||
return (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<DialogSelect
|
||||
title="Items"
|
||||
options={options}
|
||||
actions={[
|
||||
{
|
||||
command: "dialog.move_session.delete",
|
||||
title: "delete",
|
||||
onTrigger: onRow,
|
||||
},
|
||||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
selection: "none",
|
||||
onTrigger: onGlobal,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Items"))
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
return app
|
||||
}
|
||||
|
||||
async function mountSelect(root: string, initial: DialogSelectOption<string>[]) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const config = createTuiResolvedConfig()
|
||||
const [
|
||||
{ ConfigProvider },
|
||||
{ ThemeProvider },
|
||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
||||
{ DialogProvider, useDialog },
|
||||
{ DialogSelect },
|
||||
{ ToastProvider },
|
||||
] = await Promise.all([
|
||||
import("../../../src/config"),
|
||||
import("../../../src/context/theme"),
|
||||
import("../../../src/keymap"),
|
||||
import("../../../src/ui/dialog"),
|
||||
import("../../../src/ui/dialog-select"),
|
||||
import("../../../src/ui/toast"),
|
||||
])
|
||||
|
||||
const selected: string[] = []
|
||||
const moved: string[] = []
|
||||
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const [options, setOptions] = createSignal(initial)
|
||||
replaceOptions = setOptions
|
||||
onCleanup(off)
|
||||
|
||||
function Fixture() {
|
||||
const dialog = useDialog()
|
||||
onMount(() =>
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Mutable options"
|
||||
options={options()}
|
||||
onMove={(option) => moved.push(option.value)}
|
||||
onSelect={(option) => selected.push(option.value)}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 24, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Mutable options"))
|
||||
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
return { app, moved, replaceOptions, selected }
|
||||
}
|
||||
|
||||
test("dialog actions run without options while row actions still require a selection", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let global = 0
|
||||
const rows: string[] = []
|
||||
const app = await renderSelect(
|
||||
tmp.path,
|
||||
[],
|
||||
() => global++,
|
||||
(option) => rows.push(option.value),
|
||||
)
|
||||
|
||||
try {
|
||||
app.mockInput.pressKey("m", { ctrl: true })
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
expect(global).toBe(1)
|
||||
expect(rows).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("footer actions run when filtering leaves no selected row", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
let global = 0
|
||||
const rows: string[] = []
|
||||
const app = await renderSelect(
|
||||
tmp.path,
|
||||
[{ title: "Alpha", value: "alpha" }],
|
||||
() => global++,
|
||||
(option) => rows.push(option.value),
|
||||
)
|
||||
|
||||
try {
|
||||
for (const key of "missing") app.mockInput.pressKey(key)
|
||||
await app.waitForFrame((frame) => frame.includes("No results found"))
|
||||
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
app.mockInput.pressTab()
|
||||
app.mockInput.pressEnter()
|
||||
|
||||
expect(global).toBe(1)
|
||||
expect(rows).toEqual([])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("row actions receive the selected option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const rows: string[] = []
|
||||
const app = await renderSelect(
|
||||
tmp.path,
|
||||
[{ title: "Alpha", value: "alpha" }],
|
||||
() => {},
|
||||
(option) => rows.push(option.value),
|
||||
)
|
||||
|
||||
try {
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
|
||||
expect(rows).toEqual(["alpha"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("selects the new final option immediately after removing the selected final option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
|
||||
const select = await mountSelect(tmp.path, options)
|
||||
|
||||
try {
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "second")
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "third")
|
||||
select.replaceOptions(options.slice(0, -1))
|
||||
await select.app.waitForFrame((frame) => !frame.includes("third"))
|
||||
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["second"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("selects a repopulated option after removing the only option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect(tmp.path, [{ title: "only", value: "only" }])
|
||||
|
||||
try {
|
||||
select.replaceOptions([])
|
||||
await select.app.waitForFrame((frame) => frame.includes("No results found"))
|
||||
select.app.mockInput.pressEnter()
|
||||
expect(select.selected).toEqual([])
|
||||
|
||||
select.replaceOptions([{ title: "replacement", value: "replacement" }])
|
||||
await select.app.waitForFrame((frame) => frame.includes("replacement"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["replacement"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
||||
@@ -182,9 +181,7 @@ function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/c
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
@@ -174,11 +173,9 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</SDKProvider>
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from "node:path"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||
import type { FormWithLocation } from "../../../src/context/data"
|
||||
import { KVProvider } from "../../../src/context/kv"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
@@ -21,7 +20,6 @@ import { createApi, createClient, createEventStream, createFetch } from "../../f
|
||||
async function mountForm(root: string, width = 80) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const replies: unknown[] = []
|
||||
const copied: string[] = []
|
||||
@@ -78,13 +76,11 @@ async function mountForm(root: string, width = 80) {
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ConfigProvider config={config}>
|
||||
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</SDKProvider>
|
||||
</ConfigProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
|
||||
@@ -2,10 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { For } from "solid-js"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
formatCompletedSubagentDetail,
|
||||
formatSubagentRetry,
|
||||
formatSubagentTitle,
|
||||
formatSubagentToolcalls,
|
||||
InlineToolRow,
|
||||
parseApplyPatchFiles,
|
||||
parseDiagnostics,
|
||||
@@ -182,13 +180,6 @@ describe("TUI inline tool wrapping", () => {
|
||||
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
||||
})
|
||||
|
||||
test("formats completed subagent toolcall details", () => {
|
||||
expect(formatCompletedSubagentDetail(0, "501ms")).toBe("501ms")
|
||||
expect(formatCompletedSubagentDetail(1, "501ms")).toBe("1 toolcall · 501ms")
|
||||
expect(formatCompletedSubagentDetail(2, "501ms")).toBe("2 toolcalls · 501ms")
|
||||
expect(formatSubagentToolcalls(0)).toBe("0 toolcalls")
|
||||
})
|
||||
|
||||
test("keeps background state attached to the subagent identity", () => {
|
||||
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
|
||||
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
|
||||
@@ -207,5 +198,4 @@ describe("TUI inline tool wrapping", () => {
|
||||
test("snapshots expanded tool errors under the tool text", async () => {
|
||||
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ type Opts = {
|
||||
}
|
||||
|
||||
export function createTuiPluginApi(opts: Opts = {}) {
|
||||
const values = new Map<string, unknown>()
|
||||
const color = RGBA.fromInts(200, 200, 200)
|
||||
const dialog = { clear() {}, replace() {}, setSize() {}, size: "medium" as const, depth: 0, open: false }
|
||||
return {
|
||||
@@ -19,15 +18,6 @@ export function createTuiPluginApi(opts: Opts = {}) {
|
||||
client: opts.client,
|
||||
event: opts.event,
|
||||
keymap: opts.keymap,
|
||||
kv: {
|
||||
get(name: string, fallback?: unknown) {
|
||||
return values.has(name) ? values.get(name) : fallback
|
||||
},
|
||||
set(name: string, value: unknown) {
|
||||
values.set(name, value)
|
||||
},
|
||||
ready: true,
|
||||
},
|
||||
state: { session: { get: () => undefined, ...opts.state?.session } },
|
||||
theme: { current: new Proxy({}, { get: () => color }) },
|
||||
tuiConfig: createTuiResolvedConfig(),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"pretypecheck": "fumadocs-mdx",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,11 +7,6 @@ export function baseOptions(): BaseLayoutProps {
|
||||
url: "/",
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Docs",
|
||||
url: "/docs",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
url: "https://github.com/anomalyco/opencode",
|
||||
|
||||
@@ -23,6 +23,12 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Fumadocs 16.11 stretches top layout tabs across the main grid row. */
|
||||
#nd-docs-layout > div:has(> a[href="/docs/build"], > a[href="/docs/api"]) {
|
||||
align-self: start;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user