Compare commits

..

18 Commits

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

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

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

The provider-error stream event and the runner's held-back overflow
handling are unchanged here; isContextOverflowFailure now bridges old
events and new tags until the event is removed.
2026-07-13 12:21:21 -05:00
Dax Raad 9db5073eab fix(plugin): retain legacy kv api shape 2026-07-13 12:57:31 -04:00
Dax Raad 775fce0177 refactor(tui): consolidate settings in cli config 2026-07-13 12:55:54 -04:00
Dax Raad 4e74f77e44 delete context.md 2026-07-13 11:49:34 -04:00
Dax Raad b980f74ed9 refactor(tui): reuse dialog select for settings 2026-07-13 11:16:05 -04:00
218 changed files with 2762 additions and 10198 deletions
-245
View File
@@ -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 -4
View File
@@ -40,11 +40,9 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -840,6 +838,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
@@ -2219,8 +2218,6 @@
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/sdk-v1": ["@opencode-ai/sdk@https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
-2
View File
@@ -53,11 +53,9 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
@@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
@@ -263,7 +263,7 @@ function createSessionEntries(props: {
return props
.load(directory)
.then((result) =>
result.items
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
@@ -1,4 +1,4 @@
import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -31,7 +31,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
const CUSTOM_ID = "_custom"
type AuthMethod = ProviderAuthMethod & { readonly id?: string }
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -177,7 +176,6 @@ function ProviderConnection(props: {
const providers = useProviders(props.directory)
const alive = { value: true }
const connected = { value: false }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
@@ -190,16 +188,7 @@ function ProviderConnection(props: {
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const integrationID = () => {
const value = provider()
if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
return value.integrationID
}
const location = () => {
const directory = props.directory?.()
return directory ? { location: { directory } } : {}
}
const fallback = createMemo<AuthMethod[]>(() => [
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
label: language.t("provider.connect.method.apiKey"),
@@ -208,52 +197,19 @@ function ProviderConnection(props: {
const [auth] = createResource(
() => props.provider,
async () => {
const backend = await serverSDK().backend
if (backend.version === "v2") {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integration = await capability.get({ ...location(), integrationID: integrationID() })
if (!alive.value) return fallback()
return (
integration?.methods.flatMap((method): AuthMethod[] => {
if (method.type === "environment") return []
if (method.type === "key") return [{ type: "api", label: method.label }]
return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
}) ?? fallback()
)
}
const cached = serverSync().data.provider_auth[props.provider]
if (cached) return cached
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
const result = await capability.methods(location())
const res = await serverSDK().client.provider.auth()
if (!alive.value) return fallback()
const normalized = Object.fromEntries(
Object.entries(result).map(([id, methods]) => [
id,
methods.map((method) => ({
...method,
prompts: method.prompts?.map((prompt) =>
prompt.type === "select"
? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
: { ...prompt },
),
})),
]),
)
serverSync().set("provider_auth", normalized)
return normalized[props.provider] ?? fallback()
serverSync().set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
const methods = createMemo<AuthMethod[]>(() => [
...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
])
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthorization,
attemptID: undefined as string | undefined,
authorization: undefined as undefined | ProviderAuthAuthorization,
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
@@ -265,7 +221,7 @@ function ProviderConnection(props: {
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
@@ -274,7 +230,6 @@ function ProviderConnection(props: {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
@@ -283,7 +238,6 @@ function ProviderConnection(props: {
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.attemptID = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
@@ -308,7 +262,6 @@ function ProviderConnection(props: {
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.attemptID = action.attemptID
draft.error = undefined
return
}
@@ -320,16 +273,6 @@ function ProviderConnection(props: {
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
onCleanup(() => {
if (!store.attemptID || connected.value) return
void (async () => {
const backend = await serverSDK().backend
await backend.capabilities.integrationsV2
?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
.catch(() => undefined)
})()
})
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
if (value.type === "api") return language.t("provider.connect.method.apiKey")
@@ -379,33 +322,15 @@ function ProviderConnection(props: {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
const backend = await serverSDK().backend
const request =
backend.version === "v1"
? (() => {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
})()
: (() => {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!method.id) throw new Error("Provider OAuth method is missing an ID")
return capability
.connectOauth({
...location(),
integrationID: integrationID(),
methodID: method.id,
values: inputs ?? {},
})
.then((attempt) => ({
url: attempt.url,
method: attempt.mode,
instructions: attempt.instructions,
attemptID: attempt.attemptID,
}))
})()
await request
await serverSDK()
.client.provider.oauth.authorize(
{
providerID: props.provider,
method: index,
inputs,
},
{ throwOnError: true },
)
.then((x) => {
if (!alive.value) return
const elapsed = Date.now() - start
@@ -416,19 +341,11 @@ function ProviderConnection(props: {
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
dispatch({
type: "auth.complete",
authorization: x,
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
})
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
dispatch({
type: "auth.complete",
authorization: x,
attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
})
dispatch({ type: "auth.complete", authorization: x.data! })
})
.catch((e) => {
if (!alive.value) return
@@ -528,7 +445,7 @@ function ProviderConnection(props: {
<div>
<List
class="px-3"
items={[...(select()?.options ?? [])]}
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => {
@@ -581,10 +498,7 @@ function ProviderConnection(props: {
})
async function complete() {
const backend = await serverSDK().backend
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
connected.value = true
await serverSDK().client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -656,20 +570,14 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
} else {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
await capability.connectKey({
...location(),
integrationID: integrationID(),
await serverSDK().client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
})
}
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
},
})
await complete()
}
@@ -734,20 +642,13 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
const result = await (async () => {
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
})()
.then(() => ({ ok: true as const }))
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
@@ -794,26 +695,12 @@ function ProviderConnection(props: {
onMount(() => {
void (async () => {
const result = await (async () => {
const backend = await serverSDK().backend
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.callback({ providerID: props.provider, method: store.methodIndex! })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
while (alive.value) {
const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
if (status.status === "complete") return
if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
if (status.status === "expired") throw new Error("Authorization expired")
await new Promise((resolve) => setTimeout(resolve, 500))
}
})()
.then(() => ({ ok: true as const }))
const result = await serverSDK()
.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
@@ -118,7 +118,7 @@ export function CustomProviderForm() {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
})
batch(() => {
@@ -131,26 +131,23 @@ export function CustomProviderForm() {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync().data.config.disabledProviders ?? []
const disabledProviders = serverSync().data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
const backend = await serverSDK().backend
if (result.key && backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.setApiKey({ providerID: result.providerID, key: result.key })
if (result.key) {
await serverSDK().client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
key: result.key,
},
})
}
await serverSync().updateConfig({
provider: { [result.providerID]: result.config },
disabledProviders: nextDisabled,
disabled_providers: nextDisabled,
})
if (result.key && backend.version === "v2") {
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
await capability.connectKey({ integrationID: result.providerID, key: result.key })
await serverSync().refreshProviders()
}
return result
},
onSuccess: (result) => {
@@ -80,11 +80,9 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const editing = (await serverSDK().backend).capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
await editing.update({
await serverSDK().client.project.update({
projectID: props.project.id,
location: { directory: props.project.worktree },
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
+9 -11
View File
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { AppPart } from "@/context/backend"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
@@ -42,9 +42,7 @@ export const DialogFork: Component = () => {
if (message.role !== "user") continue
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find(
(x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
)
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({
@@ -71,15 +69,15 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.backend.then((client) => {
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session forking is not supported by this server")
return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
})
.client.session.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
prompt.set(restored, undefined, { dir, id: forked.data.id })
navigate(`/${dir}/session/${forked.data.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -68,8 +68,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() =>
sdk.backend
.then((client) => client.capabilities.pathInfo?.get())
sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined),
{ initialValue: undefined },
)
@@ -82,26 +83,20 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
fallbackPath()?.home ||
fallbackPath()?.directory,
)
const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
const [suggestions] = createResource(input, async (value) => {
const typed = cleanPickerInput(value).replace(/\/+$/, "")
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
if (!typed || typed === current) return { query: value, items: [] }
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.backend
.then((client) =>
client.common.files.find({
location: { directory: root() },
query: pickerFileSearchQuery(root(), value, home()),
type: "file",
limit: 20,
}),
)
const files = await sdk.client.find
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
.then((result) => result.data ?? [])
.catch(() => [])
const results = [
...directories,
...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
]
return {
query: value,
@@ -120,9 +115,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.backend
.then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
.then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
})
listings.set(key, request)
@@ -60,8 +60,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.backend
.then((client) => client.capabilities.pathInfo?.get())
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -73,7 +74,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
)
const directories = createDirectorySearch({
backend: sdk.backend,
sdk,
home,
base: start,
})
@@ -21,7 +21,6 @@ import {
pickerRoot,
pickerAbsoluteInput,
} from "./directory-picker-domain"
import { createAppClient } from "@/context/backend.test-fixture"
test("maps server directory entries into Pierre paths", () => {
expect(
@@ -133,20 +132,18 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const backend = Promise.resolve(
createAppClient({
common: {
files: {
find: (input) => {
directories.push(input.location?.directory ?? "")
return Promise.resolve([])
},
const sdk = {
client: {
find: {
files: (input: { directory: string }) => {
directories.push(input.directory)
return Promise.resolve({ data: [] })
},
},
}),
)
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
let base = "/repo"
const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
await search("components")
base = "/repo/src"
@@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
}
import { getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import type { AppClient } from "@/context/backend"
import { ServerSDK } from "@/context/server-sdk"
export function cleanPickerInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
@@ -321,11 +321,7 @@ export function displayPickerPath(path: string, input: string, home: string) {
return pickerTilde(value, home) || value
}
export function createDirectorySearch(args: {
backend: Promise<AppClient>
base: () => string | undefined
home: () => string
}) {
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
@@ -346,16 +342,14 @@ export function createDirectorySearch(args: {
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.backend
.then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((result) => result.data ?? [])
.catch(() => [])
.then((nodes) =>
nodes
.filter((node) => node.type === "directory")
.map((node) => ({
name: node.name ?? getFilename(node.path),
absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
})),
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
)
cache.set(key, request)
return request
@@ -377,18 +371,12 @@ export function createDirectorySearch(args: {
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.backend
.then((client) =>
client.common.files.find({
location: { directory: input.directory },
query,
type: "directory",
limit: 50,
}),
)
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
.catch(() => [])
if (!active()) return []
return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -1,4 +1,4 @@
import type { AppFileNode as FileNode } from "@/context/backend"
import type { FileNode } from "@opencode-ai/sdk/v2"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { AppFileNode as FileNode } from "@/context/backend"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { AppFileNode as FileNode } from "@/context/backend"
import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
+2 -2
View File
@@ -74,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { AppReference as ReferenceInfo } from "@/context/backend"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
export type PromptInputState = ReturnType<typeof usePrompt>
@@ -678,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "resource",
name: resource.name,
uri: resource.uri,
client: resource.server,
client: resource.client,
display: resource.name,
description: resource.description,
mime: resource.mimeType,
@@ -46,8 +46,6 @@ describe("buildRequestParts", () => {
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
@@ -1,12 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AppPart as Part, PromptPart } from "@/context/backend"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = PromptPart
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
type ContextFile = {
key: string
@@ -81,34 +81,6 @@ const clientFor = (directory: string) => {
beforeAll(async () => {
const rootClient = clientFor("/repo/main")
const backend = Promise.resolve({
common: {
sessions: {
create: async (input: { location?: { directory?: string } }) => {
const directory = input.location?.directory ?? "/repo/main"
createdSessions.push(directory)
return {
id: `session-${createdSessions.length}`,
projectID: "project",
location: { directory },
title: `New session ${createdSessions.length}`,
cost: 0,
time: { created: Date.now() },
}
},
prompt: async () => undefined,
command: async () => undefined,
interrupt: async () => undefined,
},
},
capabilities: {
sessionExtrasV1: {
shell: async (input: { location?: { directory?: string } }) => {
sentShell.push(input.location?.directory ?? "/repo/main")
},
},
},
})
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
@@ -117,6 +89,13 @@ beforeAll(async () => {
useSearchParams: () => [search, () => undefined],
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory)
},
}))
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0,
@@ -182,7 +161,6 @@ beforeAll(async () => {
scope: "local",
directory: "/repo/main",
client: rootClient,
backend,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -304,7 +282,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(createdClients).toEqual([])
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
@@ -463,13 +441,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
expect(storedSessions["/repo/worktree-a"]).toEqual([
expect.objectContaining({
id: "session-1",
title: "New session 1",
location: { directory: "/repo/worktree-a" },
}),
])
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
expect(optimisticSeeded).toEqual([true])
})
})
@@ -1,3 +1,4 @@
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
@@ -10,7 +11,7 @@ import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
@@ -19,7 +20,6 @@ import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import type { AppClient, AppMessage, AppSession } from "@/context/backend"
type PendingPrompt = {
abort: AbortController
@@ -39,14 +39,13 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
backend: Promise<AppClient>
client: DirectorySDK["client"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
commitRevert?: boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -54,8 +53,6 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const backend = await input.backend
const location = { directory: input.draft.sessionDirectory }
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const setBusy = () => {
@@ -84,26 +81,19 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
if (input.commitRevert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
const capability = backend.capabilities.sessionActionsV1
if (!capability) throw new Error("Commands are not supported by this server")
await capability.command({
location,
await input.client.session.command({
sessionID: input.draft.sessionID,
id: input.messageID,
command: cmd,
arguments: tail.join(" "),
agent: input.draft.agent,
model: {
providerID: input.draft.model.providerID,
id: input.draft.model.modelID,
variant: input.draft.variant,
},
files: images.map((attachment) => ({
uri: attachment.dataUrl,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
name: attachment.filename,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
return true
@@ -124,7 +114,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
sessionDirectory: input.draft.sessionDirectory,
})
const message: AppMessage = {
const message: Message = {
id: messageID,
sessionID: input.draft.sessionID,
role: "user",
@@ -162,22 +152,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
if (input.commitRevert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
await backend.common.sessions.prompt({
location,
await input.client.session.promptAsync({
sessionID: input.draft.sessionID,
id: messageID,
text,
agent: input.draft.agent,
model: input.draft.model,
messageID,
parts: requestParts,
selection: {
agent: input.draft.agent,
model: {
providerID: input.draft.model.providerID,
id: input.draft.model.modelID,
variant: input.draft.variant,
},
},
variant: input.draft.variant,
})
return true
} catch (err) {
@@ -191,7 +172,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
type PromptSubmitInput = {
prompt: ReturnType<typeof usePrompt>
info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
info: Accessor<{ id: string } | undefined>
imageAttachments: Accessor<ImageAttachmentPart[]>
commentCount: Accessor<number>
autoAccept: Accessor<boolean>
@@ -252,9 +233,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.backend.then((client) =>
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
)
.client.session.abort({
sessionID,
})
.catch(() => {})
}
@@ -281,10 +262,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (dir: string, info: AppSession) => {
const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: AppSession[]) => {
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
@@ -332,18 +313,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const backend = await sdk().backend
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await backend.capabilities.worktreesV1
?.create({ location: { directory: projectDirectory } })
.catch((err: unknown) => {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
@@ -367,6 +349,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory)
}
@@ -375,10 +361,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await sdk()
.backend.then((backend) =>
backend.common.sessions.create({ location: { directory: sessionDirectory } }),
)
const created = await client.session
.create()
.then((x) => x.data ?? undefined)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -462,22 +447,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") {
clearInput()
sdk()
.backend.then(async (backend) => {
const location = { directory: sessionDirectory }
if (session.revert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
if (backend.capabilities.sessionExtrasV1)
return backend.capabilities.sessionExtrasV1.shell({
location,
sessionID: session.id,
agent,
model: { id: model.modelID, providerID: model.providerID },
command: text,
})
if (backend.capabilities.sessionExtrasV2?.shell)
return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
throw new Error("Shell prompts are not supported by this server")
client.session
.shell({
sessionID: session.id,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
@@ -495,27 +470,21 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
sdk()
.backend.then(async (backend) => {
const location = { directory: sessionDirectory }
if (session.revert)
await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
const capability = backend.capabilities.sessionActionsV1
if (!capability) throw new Error("Commands are not supported by this server")
return capability.command({
location,
sessionID: session.id,
id: Identifier.ascending("message"),
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.filename,
})),
})
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
@@ -601,13 +570,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
backend: sdk().backend,
client,
sync: sync(),
serverSync: serverSync(),
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
commitRevert: !!session.revert,
before: waitForWorktree,
}).catch((err) => {
pending.delete(pendingKey(session.id))
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
@@ -1,4 +1,4 @@
import type { AppMessage, AppPart } from "@/context/backend"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
@@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: AppPart) => {
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: AppPart) => {
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
@@ -68,8 +68,8 @@ const build = (
}
export function estimateSessionContextBreakdown(args: {
messages: AppMessage[]
parts: Record<string, AppPart[] | undefined>
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppMessage as Message } from "@/context/backend"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
@@ -1,4 +1,4 @@
import type { AppAssistantMessage, AppMessage } from "@/context/backend"
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -14,7 +14,7 @@ type Model = {
}
type Context = {
message: AppAssistantMessage
message: AssistantMessage
provider?: Provider
model?: Model
providerLabel: string
@@ -25,11 +25,11 @@ type Context = {
usage: number | null
}
const tokenTotal = (msg: AppAssistantMessage) => {
const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
}
const lastAssistantWithTokens = (messages: AppMessage[]) => {
const lastAssistantWithTokens = (messages: Message[]) => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
@@ -38,7 +38,7 @@ const lastAssistantWithTokens = (messages: AppMessage[]) => {
}
}
const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const message = lastAssistantWithTokens(messages)
if (!message) return undefined
@@ -60,6 +60,6 @@ const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context
}
}
export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
const serverSdk = useServerSDK()
const [shells] = createResource(
async () => {
const capability = (await serverSdk().backend).capabilities.shellDiscovery
if (!capability) return []
return capability.list().catch(() => [] as ShellOption[])
},
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -12,7 +12,6 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -97,12 +96,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync().data.config.disabledProviders ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabledProviders", next)
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabledProviders: next })
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
@@ -112,47 +111,29 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabledProviders", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (item: ProviderItem) => {
const backend = await serverSDK().backend
const remove = async () => {
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.remove({ providerID: item.id })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integrationID =
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
const integration = await capability.get({ integrationID })
await Promise.all(
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
capability.removeCredential({ credentialID }),
),
)
}
if (isConfigCustom(item.id)) {
await remove().catch(() => undefined)
await disableProvider(item.id, item.name)
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await remove()
await serverSDK()
.client.auth.remove({ providerID })
.then(async () => {
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
await serverSDK().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
@@ -198,7 +179,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
</span>
}
>
<Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
<Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
@@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const capability = (await serverSdk().backend).capabilities.shellDiscovery
if (!capability) return []
return capability.list().catch(() => [] as ShellOption[])
},
() =>
serverSdk()
.client.pty.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
{ initialValue: [] as ShellOption[] },
)
@@ -12,7 +12,6 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
import { credentialConnectionIDs } from "@/context/backend"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -91,12 +90,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync().data.config.disabledProviders ?? []
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabledProviders", next)
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabledProviders: next })
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
@@ -106,47 +105,29 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabledProviders", before)
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (item: ProviderItem) => {
const backend = await serverSdk().backend
const remove = async () => {
if (backend.version === "v1") {
const capability = backend.capabilities.providerAuthV1
if (!capability) throw new Error("Server does not support provider authentication")
await capability.remove({ providerID: item.id })
return
}
const capability = backend.capabilities.integrationsV2
if (!capability) throw new Error("Server does not support provider integrations")
const integrationID =
"integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
const integration = await capability.get({ integrationID })
await Promise.all(
credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
capability.removeCredential({ credentialID }),
),
)
}
if (isConfigCustom(item.id)) {
await remove().catch(() => undefined)
await disableProvider(item.id, item.name)
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await remove()
await serverSdk()
.client.auth.remove({ providerID })
.then(async () => {
if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
if (backend.version === "v2") await serverSync().refreshProviders()
await serverSdk().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
@@ -194,7 +175,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
</span>
}
>
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
<ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</ButtonV2>
</Show>
@@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
<div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta">
<Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
<Show when={health()?.installationVersion && item.type === "http"}> </Show>
<Show when={health()?.version}>v{health()?.version}</Show>
<Show when={health()?.version && item.type === "http"}> </Show>
<Show
when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
+42 -29
View File
@@ -11,10 +11,12 @@ import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -172,8 +174,16 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
// Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
const connection = useServerSDK()().server
const directory = sdk().directory
const backend = sdk().backend
const client = sdk().client
const url = sdk().url
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -223,14 +233,11 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = (cols: number, rows: number) => {
return backend
.then((client) =>
client.common.pty.update({
ptyID: id,
size: { cols, rows },
location: { directory },
}),
)
return client.pty
.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
@@ -483,32 +490,33 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = () =>
backend
.then((client) => {
const transport = client.capabilities.ptyTransport
if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
})
client.pty
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const transport = (await backend).capabilities.ptyTransport
if (!transport) return
const result = await transport
.connectToken({ ptyID: id, location: { directory } })
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.status === 200 && result.ticket) return result.ticket
if (result.status === 404 || result.status === 405) return
if (result.status === 403)
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.status}`)
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
@@ -541,13 +549,18 @@ export const Terminal = (props: TerminalProps) => {
if (once.value) return
if (disposed) return
const transport = (await backend).capabilities.ptyTransport
if (!transport) {
fail(new Error("PTY transport is not supported by this server"))
return
}
const socket = new WebSocket(
transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
)
socket.binaryType = "arraybuffer"
ws = socket
@@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { AppSession } from "@/context/backend"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
ref?: Ref<HTMLDivElement>
href: string
server: ServerConnection.Key
session: () => AppSession | undefined
session: () => Session | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
@@ -120,10 +120,8 @@ export function TabNavItem(props: {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
const client = await ctx.sdk.backend
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session renaming is not supported by this server")
await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
await client.session.update({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
+3 -2
View File
@@ -267,8 +267,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.backend
.then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
sdk.client.session
.get({ sessionID: route.sessionId })
.then((x) => x.data)
.catch(() => {}),
)
@@ -1,70 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ServerHealth } from "@/utils/server-health"
import { backendIdentity, createBackendForServer } from "./backend-client"
const server = {
type: "http" as const,
http: {
url: "http://localhost:4096",
username: "user",
password: "secret",
},
authToken: false,
}
function setup(health: ServerHealth) {
const requests: Request[] = []
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (new URL(request.url).pathname === "/project")
return new Response("[]", { headers: { "content-type": "application/json" } })
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
headers: { "content-type": "application/json" },
})
}) as typeof globalThis.fetch
return {
requests,
fetch,
backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
}
}
describe("createBackendForServer", () => {
test("changes backend identity when credentials for the same URL change", () => {
expect(backendIdentity(server)).not.toBe(
backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
)
})
test("selects v2 and configures fetch and authentication", async () => {
const result = setup({ healthy: true, version: "v2" })
const backend = await result.backend
expect(backend.version).toBe("v2")
expect(result.requests).toHaveLength(0)
await backend.common.health.get()
expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(backend.capabilities.projectList).toBeUndefined()
expect(backend.capabilities.vcs).toBeUndefined()
expect(backend.capabilities.mcp).toBeUndefined()
expect(result.requests).toHaveLength(1)
expect(backend.version).toBe("v2")
})
test("selects v1 after fallback detection", async () => {
const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
const backend = await result.backend
expect(backend.version).toBe("v1")
expect(result.requests).toHaveLength(0)
await backend.common.health.get()
expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(backend.capabilities.projectList).toBeDefined()
expect(backend.capabilities.vcs).toBeDefined()
expect(backend.capabilities.mcp).toBeDefined()
})
})
@@ -1,64 +0,0 @@
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import type { ServerHealth } from "@/utils/server-health"
import { authTokenFromCredentials } from "@/utils/server"
import type { ServerConnection } from "./server"
import type { LocationRef } from "./backend"
import { createV1Backend } from "./backend-v1"
import { createV2Backend } from "./backend-v2"
function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return {
baseUrl: server.url,
fetch,
headers: server.password
? {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
: undefined,
}
}
export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return createOpencodeClient(options(server, fetch))
}
export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
return OpenCode.make(options(server, fetch))
}
export function backendIdentity(server: ServerConnection.Any) {
return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
server.type === "http" && server.authToken === true ? "token" : ""
}`
}
export async function createBackendForServer(input: {
server: ServerConnection.Any
browserUrl: string
fetch: typeof globalThis.fetch
eventFetch?: typeof globalThis.fetch
health: Promise<ServerHealth>
defaultLocation?: LocationRef
}) {
const health = await input.health
const eventFetch = input.eventFetch ?? input.fetch
const transport = {
baseUrl: input.server.http.url,
fetch: input.fetch,
username: input.server.http.username,
password: input.server.http.password,
sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
authToken: input.server.type === "http" && input.server.authToken === true,
}
if (health.version === "v2")
return createV2Backend(
createV2RawClient(input.server.http, input.fetch),
transport,
input.defaultLocation,
createV2RawClient(input.server.http, eventFetch),
)
const legacy = createV1RawClient(input.server.http, input.fetch)
const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
}
-407
View File
@@ -1,407 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
import type { PtyTransportConfig } from "./backend"
import { createV1Backend } from "./backend-v1"
function setup(
respond: (request: Request) => Response | Promise<Response>,
withDefault = false,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
return {
requests,
backend: createV1Backend(
client,
withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
client,
{
baseUrl: "http://localhost",
fetch,
username: "user",
password: "secret",
sameOrigin: transport?.sameOrigin ?? false,
authToken: transport?.authToken ?? false,
},
),
}
}
function json(data: unknown, headers?: HeadersInit) {
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) },
})
}
const session = {
id: "ses_1",
slug: "one",
projectID: "project",
directory: "/repo",
title: "Session",
version: "1",
time: { created: 1, updated: 2 },
}
describe("createV1Backend", () => {
test("preserves location in OAuth callbacks", async () => {
const setupResult = setup(() => json({}))
await setupResult.backend.capabilities.providerAuthV1?.callback({
providerID: "provider",
method: 1,
code: "code",
location: { directory: "/repo", workspaceID: "workspace" },
})
const url = new URL(setupResult.requests[0].url)
expect(url.searchParams.get("directory")).toBe("/repo")
expect(url.searchParams.get("workspace")).toBe("workspace")
})
test("normalizes session pagination and location", async () => {
const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/repo", workspaceID: "workspace" },
roots: true,
limit: 10,
cursor: "123",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
slug: "one",
version: "1",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: undefined },
directory: "/repo",
workspaceID: undefined,
title: "Session",
cost: 0,
tokens: undefined,
time: { created: 1, updated: 2 },
share: undefined,
revert: undefined,
},
],
older: "456",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/experimental/session")
expect(url.searchParams.get("directory")).toBe("/repo")
expect(url.searchParams.get("workspace")).toBe("workspace")
expect(url.searchParams.get("roots")).toBe("true")
expect(url.searchParams.get("cursor")).toBe("123")
})
test("converts normalized prompts to legacy parts", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
id: "msg_1",
text: "hello",
selection: {
agent: "build",
model: { id: "model", providerID: "provider", variant: "high" },
},
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }],
agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }],
})
const request = setupResult.requests[0]
expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
expect(await request.json()).toEqual({
messageID: "msg_1",
model: { providerID: "provider", modelID: "model" },
agent: "build",
variant: "high",
parts: [
{ type: "text", text: "hello" },
{ type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" },
{ type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } },
],
})
})
test("preserves ordered prompt part IDs and metadata", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "visible",
parts: [
{ id: "part_text", type: "text", text: "visible" },
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
],
})
expect((await setupResult.requests[0].json()).parts).toEqual([
{ id: "part_text", type: "text", text: "visible" },
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
])
})
test("combines mixed file search and decodes binary content", async () => {
const setupResult = setup((request) => {
const url = new URL(request.url)
if (url.pathname === "/find/file") {
return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"])
}
return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" })
})
const found = await setupResult.backend.common.files.find({ query: "a" })
const content = await setupResult.backend.common.files.read({ path: "a.bin" })
expect(found).toEqual([
{ path: "a.txt", type: "file" },
{ path: "shared", type: "directory" },
{ path: "dir", type: "directory" },
])
expect([...content.bytes]).toEqual([0, 1, 2])
expect(content.kind).toBe("binary")
expect(content.mimeType).toBe("application/octet-stream")
})
test("preserves project, provider, and file metadata", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/project")
return json([
{ id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
])
if (path === "/provider")
return json({
all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
connected: [],
default: {},
})
return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
})
const projectList = setupResult.backend.capabilities.projectList
if (!projectList) throw new Error("Missing project list capability")
const projects = await projectList.list()
const providers = await setupResult.backend.common.catalog.providers()
const files = await setupResult.backend.common.files.list({})
expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
expect(providers.providers.get("provider")?.source).toBe("config")
expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
})
test("uses explicit session locations and preserves mutation confirmations", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
return json(session)
}, true)
const history = await setupResult.backend.common.sessions.history({
sessionID: "ses_1",
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
})
const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
sessionID: "ses_1",
messageID: "msg_1",
})
const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
expect(history.older).toBe("older")
expect(removed).toBe(true)
expect(reverted?.id).toBe("ses_1")
expect(cleared?.id).toBe("ses_1")
expect(worktreeRemoved).toBe(true)
expect(worktreeReset).toBe(true)
const urls = setupResult.requests.map((request) => new URL(request.url))
expect(urls[0].searchParams.get("directory")).toBe("/explicit")
expect(urls[1].searchParams.get("directory")).toBe("/default")
})
test("normalizes idle and compatibility events", async () => {
const events = [
{ type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
{
type: "todo.updated",
properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
},
{
type: "message.part.delta",
properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
},
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
{ type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
{ type: "lsp.updated", properties: {} },
{ type: "reference.updated", properties: {} },
{ type: "mcp.tools.changed", properties: { server: "docs" } },
{ type: "server.instance.disposed", properties: { directory: "/repo" } },
]
const setupResult = setup(
() =>
new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
const result = await Promise.all(events.map(() => iterator.next()))
expect(result.map((item) => item.value?.event.type)).toEqual([
"session.activity",
"todo.updated",
"timeline.delta",
"timeline.part.removed",
"worktree.ready",
"lsp.updated",
"reference.updated",
"mcp.updated",
"instance.disposed",
])
expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
})
test("updates the V1 projection cache for deltas and removals", async () => {
const info = {
id: "msg_1",
sessionID: "ses_1",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "p", modelID: "m" },
}
const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
const events = [
{ type: "message.updated", properties: { info } },
{ type: "message.part.updated", properties: { sessionID: "ses_1", part } },
{ type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
{ type: "message.updated", properties: { info } },
{ type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
{ type: "message.updated", properties: { info } },
]
const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
await iterator.next()
await iterator.next()
const afterDelta = await iterator.next()
await iterator.next()
const afterRemoval = await iterator.next()
expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
expect(setupResult.requests).toHaveLength(1)
})
test("merges global config updates with untouched fields", async () => {
const bodies: unknown[] = []
const setupResult = setup(async (request) => {
if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] })
bodies.push(await request.json())
return json({})
})
await setupResult.backend.capabilities.configuration?.updateGlobal({
model: "new",
disabledProviders: ["two"],
})
expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
})
test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
if (request.method === "GET") return new Response(null, { status: 404 })
return new Response(null, { status: 204 })
})
await setupResult.backend.common.permissions.reply({
sessionID: "ses_1",
requestID: "per_1",
reply: "once",
location: { directory: "/explicit", workspaceID: "workspace" },
})
await setupResult.backend.common.questions.reject({
sessionID: "ses_1",
requestID: "que_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const transport = setupResult.backend.capabilities.ptyTransport
const ticket = await transport?.connectToken({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const exists = await transport?.exists({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
expect(ticket).toEqual({ status: 405, ticket: undefined })
expect(exists).toBe(false)
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
"/explicit",
"/explicit",
"/explicit",
"/explicit",
])
expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
const fallback = transport?.connectURL({
ptyID: "pty/1",
location: { directory: "/explicit", workspaceID: "workspace" },
cursor: 12,
})
expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
expect(fallback?.protocol).toBe("ws:")
expect(fallback?.searchParams.get("directory")).toBe("/explicit")
expect(fallback?.searchParams.get("workspace")).toBe("workspace")
expect(fallback?.searchParams.get("cursor")).toBe("12")
expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
const ticketURL = transport?.connectURL({
ptyID: "pty_1",
location: { directory: "/explicit" },
cursor: -1,
ticket: "ticket value",
})
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
})
test("preserves same-origin auth-token policy for PTY URLs", () => {
const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
.ptyTransport
const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
})
})
File diff suppressed because it is too large Load Diff
-745
View File
@@ -1,745 +0,0 @@
import { describe, expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
import { createV2Backend } from "./backend-v2"
function setup(
respond: (request: Request) => Response | Promise<Response>,
transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
) {
const requests: Request[] = []
const fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
return respond(request)
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
return {
requests,
backend: createV2Backend(
client,
{
baseUrl: "http://localhost",
fetch,
username: "user",
password: transport && "password" in transport ? transport.password : "secret",
sameOrigin: transport?.sameOrigin ?? false,
authToken: transport?.authToken ?? false,
},
{ directory: "/default", workspaceID: "default-workspace" },
client,
),
}
}
function json(data: unknown) {
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
}
const session = {
id: "ses_1",
projectID: "project",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
title: "Session",
location: { directory: "/repo", workspaceID: "workspace" },
}
describe("createV2Backend", () => {
test("uses no legacy endpoints for bootstrap and common reads", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/api/health") return json({ healthy: true, version: "v2" })
if (path === "/api/location")
return json({
directory: "/default",
workspaceID: "default-workspace",
project: { id: "project", directory: "/default" },
})
return json({ location: { directory: "/default" }, data: [] })
})
await Promise.all([
setupResult.backend.common.health.get(),
setupResult.backend.common.projects.current(),
setupResult.backend.common.catalog.providers(),
setupResult.backend.common.catalog.agents(),
setupResult.backend.common.commands.list(),
setupResult.backend.common.references.list(),
setupResult.backend.common.files.list({}),
setupResult.backend.common.files.find({ query: "src" }),
setupResult.backend.common.permissions.pending(),
setupResult.backend.common.questions.pending(),
setupResult.backend.common.pty.list(),
])
const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
false,
)
})
test("normalizes session pages and preserves location precedence", async () => {
const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
const result = await setupResult.backend.common.sessions.list({
location: { directory: "/explicit", workspaceID: "explicit-workspace" },
limit: 10,
cursor: "cursor",
})
expect(result).toEqual({
items: [
{
id: "ses_1",
slug: "ses_1",
version: "",
parentID: undefined,
projectID: "project",
location: { directory: "/repo", workspaceID: "workspace" },
directory: "/repo",
workspaceID: "workspace",
title: "Session",
cost: 1.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: 10, updated: 20 },
revert: undefined,
},
],
newer: "before",
older: "after",
})
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/session")
expect(url.searchParams.get("directory")).toBe("/explicit")
expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
expect(url.searchParams.has("parentID")).toBe(false)
expect(url.searchParams.get("cursor")).toBe("cursor")
})
test("paginates native sessions until roots:true contains only the requested roots", async () => {
const child = { ...session, id: "child", parentID: "root_1" }
const root1 = { ...session, id: "root_1" }
const root2 = { ...session, id: "root_2" }
const setupResult = setup((request) => {
const cursor = new URL(request.url).searchParams.get("cursor")
if (!cursor) return json({ data: [child], cursor: { next: "one" } })
if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
return json({ data: [root2], cursor: { next: "three" } })
})
const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
expect(result.older).toBe("three")
expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
})
test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
const setupResult = setup((request) => {
if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
return json({ location: { directory: "/default" }, data: [] })
})
await setupResult.backend.common.files.list({ path: "dir" })
const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
const url = new URL(setupResult.requests[0].url)
expect(url.pathname).toBe("/api/fs/list")
expect(url.searchParams.get("location[directory]")).toBe("/default")
expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
expect(content).toEqual({
bytes: Uint8Array.from([0, 1, 255]),
kind: "binary",
mimeType: "application/octet-stream",
})
const readURL = new URL(setupResult.requests[1].url)
expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
expect(readURL.searchParams.get("location[directory]")).toBe("/default")
expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
expect(setupResult.backend.version).toBe("v2")
expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
"integrationsV2",
"projectCopiesV2",
"ptyTransport",
"savedPermissionsV2",
"sessionExtrasV2",
])
expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
expect(setupResult.backend.capabilities.projectList).toBeUndefined()
expect(setupResult.backend.capabilities.vcs).toBeUndefined()
expect(setupResult.backend.capabilities.mcp).toBeUndefined()
expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
})
test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
const setupResult = setup((request) =>
request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
)
const transport = setupResult.backend.capabilities.ptyTransport
const ticket = await transport?.connectToken({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
const exists = await transport?.exists({
ptyID: "pty_1",
location: { directory: "/explicit", workspaceID: "workspace" },
})
expect(ticket).toEqual({ status: 403, ticket: undefined })
expect(exists).toBe(false)
expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
const tokenURL = new URL(setupResult.requests[0].url)
const existsURL = new URL(setupResult.requests[1].url)
expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
expect(existsURL.pathname).toBe("/api/pty/pty_1")
expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
expect(() =>
transport?.connectURL({
ptyID: "pty/1",
location: { directory: "/explicit", workspaceID: "workspace" },
cursor: 8,
}),
).toThrow("require a ticket")
const ticketURL = transport?.connectURL({
ptyID: "pty_1",
location: { directory: "/explicit" },
cursor: 0,
ticket: "ticket value",
})
expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
})
test("allows ticketless same-origin native PTY URLs without credential queries", () => {
const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
.ptyTransport
const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
expect(url?.searchParams.has("auth_token")).toBe(false)
expect(url?.pathname).toBe("/api/pty/pty_1/connect")
})
test("preserves native provider integration IDs", async () => {
const setupResult = setup((request) => {
const path = new URL(request.url).pathname
if (path === "/api/provider")
return json({
location: { directory: "/default" },
data: [
{
id: "provider",
integrationID: "integration",
name: "Provider",
api: { type: "native", settings: {} },
request: { headers: {}, body: {} },
},
],
})
return json({ location: { directory: "/default" }, data: [] })
})
const result = await setupResult.backend.common.catalog.providers()
expect(result.providers.get("provider")?.integrationID).toBe("integration")
})
test("preserves integration connection kinds", async () => {
const setupResult = setup(() =>
json({
location: { directory: "/default" },
data: [
{
id: "integration",
name: "Integration",
methods: [],
connections: [
{ type: "credential", id: "credential", label: "Saved" },
{ type: "environment", name: "TOKEN" },
],
},
],
}),
)
const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
expect(integrations).toEqual([
{
id: "integration",
name: "Integration",
methods: [],
connections: [
{ id: "credential", label: "Saved", kind: "credential" },
{ id: "TOKEN", label: "TOKEN", kind: "environment" },
],
},
])
expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
})
test("switches prompt selection before admission", async () => {
const setupResult = setup((request) =>
request.url.endsWith("/prompt")
? json({
data: {
admittedSeq: 1,
id: "msg_1",
sessionID: "ses_1",
timeCreated: 1,
type: "user",
data: { text: "hello" },
delivery: "steer",
},
})
: new Response(null, { status: 204 }),
)
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: { agent: "build", model: { id: "model", providerID: "provider", variant: "high" } },
files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", source: { text: "hi", start: 0, end: 2 } }],
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/model",
"/api/session/ses_1/prompt",
])
expect(await setupResult.requests[2].json()).toEqual({
id: "msg_1",
prompt: {
text: "hello",
files: [
{
uri: "data:text/plain;base64,aGk=",
mime: "application/octet-stream",
name: "hi.txt",
source: { start: 0, end: 2, text: "hi" },
},
],
},
})
})
test("serializes selection and prompt admission per session", async () => {
const firstPrompt = Promise.withResolvers<void>()
const firstPromptStarted = Promise.withResolvers<void>()
const setupResult = setup(async (request) => {
const path = new URL(request.url).pathname
if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
firstPromptStarted.resolve()
await firstPrompt.promise
}
return path.endsWith("/prompt")
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
: new Response(null, { status: 204 })
})
const first = setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "first",
selection: { agent: "build" },
})
await firstPromptStarted.promise
const second = setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_2",
text: "second",
selection: { agent: "plan" },
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
])
firstPrompt.resolve()
await Promise.all([first, second])
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
"/api/session/ses_1/agent",
"/api/session/ses_1/prompt",
])
})
test("does not switch a selection already present in session state", async () => {
const setupResult = setup((request) =>
request.url.endsWith("/prompt")
? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
: json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
)
await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "hello",
selection: { agent: "build", model: { id: "model", providerID: "provider" } },
})
expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
"/api/session/ses_1",
"/api/session/ses_1/prompt",
])
})
test("requests file staging when selected revert files are present", async () => {
const setupResult = setup(() => json({ data: { messageID: "msg_1", files: [] } }))
await setupResult.backend.capabilities.sessionExtrasV2?.stageRevert({
sessionID: "ses_1",
messageID: "msg_1",
files: ["a.txt"],
})
expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
})
test("maps ordered app prompt parts to the native prompt shape", async () => {
const setupResult = setup(() =>
json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
)
await setupResult.backend.common.sessions.prompt({
sessionID: "ses_1",
id: "msg_1",
text: "visible",
parts: [
{ id: "part_text", type: "text", text: "visible" },
{ id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
{ id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
{ id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
],
})
expect(await setupResult.requests[0].json()).toEqual({
id: "msg_1",
prompt: {
text: "visiblenote",
files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
},
})
})
test("commits a staged revert through the V2 capability", async () => {
const setupResult = setup(() => new Response(null, { status: 204 }))
await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
})
test("normalizes current session activity events without projection refresh", async () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_started",
type: "session.next.step.started",
durable: { aggregateID: "ses_1", seq: 3, version: 1 },
location: { directory: "/repo" },
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toMatchObject({
location: { directory: "/repo" },
event: {
type: "session.activity",
sessionID: "ses_1",
activity: { type: "running" },
},
})
expect(setupResult.requests).toHaveLength(1)
})
test("maps completed native steps to idle activity", async () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_ended",
type: "session.next.step.ended",
data: {
timestamp: 2,
sessionID: "ses_1",
assistantMessageID: "msg_1",
finish: "stop",
cost: 1,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value?.event).toEqual({
type: "session.activity",
sessionID: "ses_1",
activity: { type: "idle" },
})
})
test("projects failed steps as completed assistant errors and clears running", async () => {
const events = [
{
id: "start",
type: "session.next.step.started",
data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
},
{ id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
{ id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
{ id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
await iterator.next()
await iterator.next()
expect((await iterator.next()).value?.event).toMatchObject({
type: "session.activity",
activity: { type: "idle" },
item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
})
})
test("does not infer assistant parents across history pages or direct fetches", async () => {
const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
const setupResult = setup((request) => {
const url = new URL(request.url)
if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
return json({ data: [assistant], cursor: { next: "older" } })
})
const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
})
test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
const events = [
{ id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
{ id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
{ id: "integration", type: "integration.updated", data: {} },
{ id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
expect((await iterator.next()).value?.event.type).toBe("unknown")
expect(setupResult.requests).toHaveLength(1)
})
test("normalizes native session create, update, and delete lifecycle events", async () => {
const info = {
id: "ses_1",
slug: "one",
version: "2",
projectID: "project",
directory: "/repo",
title: "Session",
time: { created: 1, updated: 2 },
}
const events = [
{ id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
{ id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
{ id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
]
const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
})
test("normalizes durable session log events through the event mapper", async () => {
const setupResult = setup(
() =>
new Response(
`data: ${JSON.stringify({
id: "evt_started",
type: "session.next.step.started",
durable: { aggregateID: "ses_1", seq: 7, version: 1 },
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
const capability = setupResult.backend.capabilities.sessionExtrasV2
if (!capability) throw new Error("Missing V2 session capability")
const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
expect(result.value).toMatchObject({
sequence: 7,
event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
})
expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
})
test("normalizes native todo and part removal events", async () => {
const events = [
{
id: "evt_todo",
type: "todo.updated",
data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
},
{
id: "evt_removed",
type: "message.part.removed",
data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
},
]
const setupResult = setup(
() =>
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
expect((await iterator.next()).value?.event).toEqual({
type: "todo.updated",
sessionID: "ses_1",
todos: [{ content: "Ship", status: "pending", priority: "high" }],
})
expect((await iterator.next()).value?.event).toEqual({
type: "timeline.part.removed",
sessionID: "ses_1",
itemID: "msg_1",
contentID: "part_1",
})
})
test("preserves streamed V2 timeline deltas without projection refresh", async () => {
const setupResult = setup((request) => {
if (new URL(request.url).pathname === "/api/event") {
return new Response(
`data: ${JSON.stringify({
id: "evt_1",
type: "session.next.text.delta",
location: { directory: "/repo" },
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
textID: "text_1",
delta: "hi",
},
})}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
}
return json({
data: {
id: "msg_1",
type: "assistant",
time: { created: 1 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", id: "text_1", text: "hello" }],
},
})
})
const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
expect(result.value).toEqual({
location: { directory: "/repo" },
event: {
type: "timeline.delta",
sessionID: "ses_1",
itemID: "msg_1",
contentID: "text_1",
field: "text",
delta: "hi",
},
})
expect(setupResult.requests).toHaveLength(1)
})
test("projects native fragment starts without blocking the event stream on HTTP", async () => {
const events = [
{
id: "evt_step",
type: "session.next.step.started",
data: {
timestamp: 1,
sessionID: "ses_1",
assistantMessageID: "msg_1",
agent: "build",
model: { id: "model", providerID: "provider" },
},
},
{
id: "evt_text",
type: "session.next.text.started",
data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
},
]
const setupResult = setup(
() =>
new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
}),
)
const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
await iterator.next()
expect((await iterator.next()).value?.event).toEqual({
type: "timeline.content.updated",
sessionID: "ses_1",
itemID: "msg_1",
content: { type: "text", id: "text_1", text: "" },
})
expect(setupResult.requests).toHaveLength(1)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,56 +0,0 @@
import type { AppClient, Capabilities, CommonClient } from "./backend"
type PartialApi<T> = {
[K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
}
export function createAppClient(input: {
version?: AppClient["version"]
common?: PartialApi<CommonClient>
capabilities?: Capabilities
} = {}): AppClient {
const unsupported = async () => {
throw new Error("Backend fixture method is not configured")
}
const defaults: CommonClient = {
health: { get: unsupported },
projects: { current: unsupported },
catalog: { providers: unsupported, agents: unsupported },
commands: { list: unsupported },
references: { list: unsupported },
sessions: {
list: unsupported,
create: unsupported,
get: unsupported,
interrupt: unsupported,
activity: unsupported,
history: unsupported,
message: unsupported,
prompt: unsupported,
},
files: { list: unsupported, find: unsupported, read: unsupported },
permissions: { pending: unsupported, reply: unsupported },
questions: { pending: unsupported, reply: unsupported, reject: unsupported },
pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
events: {
async *subscribe() {},
},
}
return {
version: input.version ?? "v1",
capabilities: input.capabilities ?? {},
common: {
health: { ...defaults.health, ...input.common?.health },
projects: { ...defaults.projects, ...input.common?.projects },
catalog: { ...defaults.catalog, ...input.common?.catalog },
commands: { ...defaults.commands, ...input.common?.commands },
references: { ...defaults.references, ...input.common?.references },
sessions: { ...defaults.sessions, ...input.common?.sessions },
files: { ...defaults.files, ...input.common?.files },
permissions: { ...defaults.permissions, ...input.common?.permissions },
questions: { ...defaults.questions, ...input.common?.questions },
pty: { ...defaults.pty, ...input.common?.pty },
events: { ...defaults.events, ...input.common?.events },
},
}
}
File diff suppressed because it is too large Load Diff
+13 -21
View File
@@ -1,7 +1,7 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { AppMessage, AppPart, AppSession } from "./backend"
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js"
import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { State } from "./global-sync/types"
@@ -23,8 +23,8 @@ export const createDirSyncContext = (
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const client = serverSDK.createClient({ directory, throwOnError: true })
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, {
get(_, property: keyof State) {
@@ -72,7 +72,7 @@ export const createDirSyncContext = (
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: AppSession) {
remember(session: Session) {
serverSync.session.remember(session)
index(session.id)
},
@@ -81,7 +81,7 @@ export const createDirSyncContext = (
if (session?.directory === directory) return session
},
optimistic: {
add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
serverSync.session.optimistic.add(input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
@@ -91,7 +91,7 @@ export const createDirSyncContext = (
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: AppPart[]
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
@@ -110,7 +110,7 @@ export const createDirSyncContext = (
})
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, { ...options, location: { directory } })
await serverSync.session.sync(sessionID, options)
index(sessionID)
},
diff: serverSync.session.diff,
@@ -121,25 +121,17 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const backend = await serverSDK.backend
const response = await backend.common.sessions.list({
location: { directory },
roots: true,
limit: count,
cursor: sessionPage.cursor,
})
const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
const response = await client.session.list()
const sessions = (response.data ?? [])
.filter((session) => !!session?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" }))
setSessionPage({ cursor: response.older, complete: !response.older })
},
more: createMemo(() => !sessionPage.complete),
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const backend = await serverSDK.backend
const capability = backend.capabilities.sessionExtrasV1
if (!capability) throw new Error("Server does not support session archiving")
await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
current()[1](
"session",
produce((draft) => {
+9 -33
View File
@@ -79,16 +79,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const tree = createFileTreeStore({
scope,
normalizeDir: path.normalizeDir,
list: async (dir) =>
[...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
(node) => ({
name: node.name ?? getFilename(node.path),
path: node.path,
absolute: node.absolute ?? node.path,
type: node.type,
ignored: node.ignored,
}),
),
list: (dir) =>
sdk()
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
onError: (message) => {
showToast({
variant: "error",
@@ -187,18 +181,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.backend.then(async (client) => {
.client.file.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content: FileState["content"] = client.capabilities.decoratedFiles
? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
: await client.common.files.read({ location: { directory }, path: file }).then((value) => {
if (value.kind !== "text") return
return {
type: "text" as const,
content: new TextDecoder().decode(value.bytes),
mimeType: value.mimeType,
}
})
const content = x.data
setLoaded(file, content)
if (!content) return
@@ -219,19 +205,9 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
sdk()
.backend.then((client) =>
client.common.files.find(
{
location: { directory: scope() },
query,
type: dirs === "true" ? undefined : "file",
limit: options?.limit,
},
{ signal: options?.signal },
),
)
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
.then(
(items) => items.map((item) => path.normalize(item.path)),
(x) => (x.data ?? []).map(path.normalize),
(error) => {
if (options?.signal?.aborted) throw error
return []
@@ -1,4 +1,4 @@
import type { DecoratedFileContent as FileContent } from "../backend"
import type { FileContent } from "@opencode-ai/sdk/v2"
const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import type { AppFileNode as FileNode } from "../backend"
import type { FileNode } from "@opencode-ai/sdk/v2"
type DirectoryState = {
expanded: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DecoratedFileContent as FileContent } from "../backend"
import type { FileContent } from "@opencode-ai/sdk/v2"
export type FileSelection = {
startLine: number
+5 -7
View File
@@ -1,10 +1,8 @@
import type { AppFileNode as FileNode } from "../backend"
import type { FileNode } from "@opencode-ai/sdk/v2"
type WatcherEvent = {
type: string
path?: string
change?: string
properties?: unknown
properties: unknown
}
type WatcherOps = {
@@ -18,11 +16,11 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
const rawPath = typeof props?.file === "string" ? props.file : undefined
const kind = typeof props?.event === "string" ? props.event : undefined
if (!rawPath) return
if (!kind) return
@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
import { createAppClient } from "../backend.test-fixture"
import type { ProviderStore } from "./types"
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { createServerSession } from "../server-session"
import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
function directoryState() {
return createStore<State>({
@@ -31,7 +30,6 @@ function directoryState() {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
@@ -57,35 +55,33 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local,
mcp: false,
global: {
config: {},
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
backend: {
version: "v1",
capabilities: {
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
vcsInfo: { get: async () => ({}) },
},
common: {
catalog: {
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
mcpReads.push("command")
return { data: [] }
},
sessions: { activity: async () => ({}) },
projects: { current: async () => ({ id: "project", directory: "/project" }) },
commands: {
list: async () => {
mcpReads.push("command")
return []
},
},
permissions: { pending: async () => [] },
questions: { pending: async () => [] },
references: { list: async () => [] },
},
} as unknown as AppClient,
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: {
status: async () => {
mcpReads.push("status")
return { data: {} }
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -105,26 +101,22 @@ describe("bootstrapDirectory", () => {
test("seeds session status even while warming session info stalls", async () => {
const [store, setStore] = directoryState()
const stalled = Promise.withResolvers<never>()
const backend = createAppClient({
version: "v1",
capabilities: {
configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
vcsInfo: { get: async () => ({}) },
const client = {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: {
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
get: () => stalled.promise,
},
common: {
catalog: {
agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
},
sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
projects: { current: async () => ({ id: "project", directory: "/project" }) },
commands: { list: async () => [] },
permissions: { pending: async () => [] },
questions: { pending: async () => [] },
references: { list: async () => [] },
},
})
const session = createServerSession(backend)
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient
const session = createServerSession(client)
const stale: Session = {
id: "ses_stale",
slug: "ses_stale",
@@ -142,12 +134,12 @@ describe("bootstrapDirectory", () => {
scope: ServerScope.local,
mcp: false,
global: {
config: {},
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
backend,
sdk: client,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -169,12 +161,12 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const backend = Promise.resolve({} as AppClient)
const client = {} as OpencodeClient
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
"https://debian.example",
null,
"providers",
+94 -167
View File
@@ -1,35 +1,35 @@
import type {
AppClient,
AppConfig,
AppPathInfo,
AppPermissionRequest,
AppProject,
AppProviderAuthResponse,
AppQuestionRequest,
AppReference,
AppSession,
ProviderCatalog,
} from "../backend"
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { retry } from "@opencode-ai/core/util/retry"
import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { ProviderStore, State, StoreConfig, VcsCache } from "./types"
import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session"
import { cmp } from "./utils"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = {
ready: boolean
path: AppPathInfo
project: AppProject[]
provider: ProviderStore
provider_auth: AppProviderAuthResponse
config: StoreConfig
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
@@ -82,31 +82,29 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}),
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
})
export const loadProjectsQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
backend
.then((client) => client.capabilities.projectList?.list() ?? [])
.then((projects) => {
return projects
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
sdk.project.list().then((x) => {
return (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
),
})
export async function bootstrapGlobal(input: {
backend: Promise<AppClient>
serverSDK: OpencodeClient
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
@@ -115,12 +113,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.backend))
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -142,11 +140,11 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {})
}
function projectID(directory: string, projects: readonly AppProject[]) {
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
setStore("session", (list) => {
const next = list.slice()
const idx = next.findIndex((item) => item.id >= session.id)
@@ -164,54 +162,44 @@ function warmSessions(input: {
ids: string[]
store: Store<State>
setStore: SetStoreFunction<State>
backend: AppClient
location: { directory: string }
sdk: OpencodeClient
}) {
const known = new Set(input.store.session.map((item) => item.id))
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
if (ids.length === 0) return Promise.resolve()
return Promise.all(
ids.map((sessionID) =>
retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
if (!x?.id) return
mergeSession(input.setStore, x)
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
const session = x.data
if (!session?.id) return
mergeSession(input.setStore, session)
}),
),
).then(() => undefined)
}
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () =>
retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))),
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
})
export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "agents"],
queryFn: () =>
retry(() =>
backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])),
),
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
})
export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
queryOptions<AppPathInfo>({
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: async () => {
const client = await backend
return retry(
() => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)),
)
},
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
})
export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<readonly AppReference[]>({
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () =>
retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []),
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
placeholderData: [],
})
@@ -219,17 +207,17 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
backend: AppClient
sdk: OpencodeClient
store: Store<State>
setStore: SetStoreFunction<State>
vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string
global: {
config: StoreConfig
path: AppPathInfo
project: readonly AppProject[]
provider: ProviderStore
config: Config
path: Path
project: Project[]
provider: NormalizedProviderListResponse
}
queryClient: QueryClient
session?: ServerSession
@@ -252,90 +240,66 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
.then((data) => input.setStore("agent", data)),
() =>
retry(
() =>
input.backend.capabilities.configuration
?.get(location(input.directory))
.then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(),
),
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() =>
retry(() =>
input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
input.sdk.session.status().then(async (x) => {
if (!input.session) {
input.setStore("session_status", mapActivity(statuses))
input.setStore("session_status", x.data!)
return
}
const mapped = mapActivity(statuses)
const statuses = x.data ?? {}
input.session.set(
"session_status",
produce((draft) => {
for (const sessionID of Object.keys(draft)) {
if (mapped[sessionID]) continue
if (statuses[sessionID]) continue
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
}
}),
)
for (const [sessionID, status] of Object.entries(mapped)) {
for (const [sessionID, status] of Object.entries(statuses)) {
input.session.set("session_status", sessionID, reconcile(status))
}
// Warm session info only after seeding statuses so a stalled session
// fetch cannot park busy indicators behind it, mirroring how live
// session.status events apply first and resolve info in the background.
await Promise.all(
Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
)
}),
),
!seededProject &&
(() =>
retry(() => input.backend.common.projects.current(location(input.directory))).then((project) =>
input.setStore("project", project.id),
)),
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(() =>
(input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
(data) => {
const next = data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
},
),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
input.mcp &&
(() =>
retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) =>
input.setStore("command", reconcile(commands)),
)),
() =>
input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))),
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
() =>
retry(() =>
input.backend.common.permissions.pending(location(input.directory)).then((data) => {
const permissions = data.map(
(perm): AppPermissionRequest => perm,
input.sdk.permission.list().then((x) => {
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
const ids = permissions.map((perm) => perm.sessionID)
const grouped = groupBySession(permissions)
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({
ids,
store: input.store,
setStore: input.setStore,
backend: input.backend,
location: { directory: input.directory },
})
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => {
const current = input.session?.data.permission ?? input.store.permission
@@ -359,21 +323,12 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
input.backend.common.questions.pending(location(input.directory)).then((data) => {
const questions = data.map(
(question): AppQuestionRequest => question,
)
const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession(questions)
input.sdk.question.list().then((x) => {
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({
ids,
store: input.store,
setStore: input.setStore,
backend: input.backend,
location: { directory: input.directory },
})
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
return warm.then(() =>
batch(() => {
const current = input.session?.data.question ?? input.store.question
@@ -396,25 +351,17 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)),
)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint()
@@ -432,23 +379,3 @@ export async function bootstrapDirectory(input: {
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
})()
}
function location(directory: string | null) {
return directory === null ? undefined : { location: { directory } }
}
function emptyPath(directory: string | null): AppPathInfo {
return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" }
}
function toProviderStore(input: ProviderCatalog): ProviderStore {
return {
all: input.providers,
connected: [...input.connected],
default: { ...input.defaults },
}
}
function mapActivity(input: Awaited<ReturnType<AppClient["common"]["sessions"]["activity"]>>) {
return input
}
@@ -1,7 +1,8 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { ProviderStore, State } from "./types"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope"
@@ -15,7 +16,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
]
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
@@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { AppVcsInfo } from "../backend"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -10,7 +10,6 @@ import {
type IconCache,
type MetaCache,
type ProjectMeta,
type ProviderStore,
type State,
type VcsCache,
} from "./types"
@@ -18,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
@@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
global: {
provider: ProviderStore
provider: NormalizedProviderListResponse
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
const vcs = runWithOwner(input.owner, () =>
input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as AppVcsInfo | undefined }),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
@@ -223,7 +223,6 @@ export function createChildStoreManager(input: {
return (type ?? "idle") !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
get mcp_ready() {
@@ -1,12 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AppMessage as Message,
AppPart as Part,
AppPermissionRequest as PermissionRequest,
AppProject as Project,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "../backend"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -46,9 +39,7 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
id,
sessionID,
permission: title,
action: title,
patterns: ["*"],
resources: ["*"],
metadata: {},
always: [],
}) as PermissionRequest
@@ -532,9 +523,9 @@ describe("applyDirectoryEvent", () => {
})
test("updates vcs branch in store and cache", () => {
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } }))
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
const [cacheStore, setCacheStore] = createStore({
value: { branch: "main", defaultBranch: "main" } as State["vcs"],
value: { branch: "main", default_branch: "main" } as State["vcs"],
})
applyDirectoryEvent({
@@ -551,8 +542,8 @@ describe("applyDirectoryEvent", () => {
},
})
expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
})
test("routes disposal and lsp events to side-effect handlers", () => {
@@ -1,18 +1,15 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
AppEvent,
AppFileDiff,
AppMessage,
AppPart,
AppPermissionRequest,
AppProject,
AppQuestionRequest,
AppSession,
AppTodo,
SessionActivity,
} from "../backend"
import { timelineMessage } from "../backend"
Message,
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -34,76 +31,19 @@ const SESSION_CONTENT_EVENTS = new Set([
"question.rejected",
])
type LegacyEvent = { type: string; properties?: unknown }
type DirectoryLegacyEvent =
| { type: string; properties: unknown }
| { type: "server.instance.disposed"; properties?: undefined }
function legacyEvent(event: AppEvent): LegacyEvent | undefined {
if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event }
if (event.type === "session.created" || event.type === "session.updated")
return { type: event.type, properties: { info: event.session } }
if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } }
if (event.type === "session.activity")
return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }
if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event }
if (event.type === "timeline.updated") {
const message = timelineMessage(event.item)
return message ? { type: "message.updated", properties: { info: message } } : undefined
}
if (event.type === "timeline.content.updated") return undefined
if (event.type === "timeline.removed")
return {
type: "message.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID },
}
if (event.type === "timeline.part.removed")
return {
type: "message.part.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID },
}
if (event.type === "timeline.delta")
return {
type: "message.part.delta",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
field: event.field,
delta: event.delta,
},
}
if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request }
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected")
return { type: event.type, properties: event }
if (event.type === "question.requested") return { type: "question.asked", properties: event.request }
return { type: event.type, properties: event }
}
function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) {
if ("properties" in event) return event
if (event.type === "server.instance.disposed") return event
return legacyEvent(event)
}
export function applyGlobalEvent(input: {
event: AppEvent | LegacyEvent
project: AppProject[]
setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void
event: { type: string; properties?: unknown }
project: Project[]
setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
refresh: () => void
}) {
if (
input.event.type === "server.disposed" ||
input.event.type === "global.disposed" ||
input.event.type === "server.connected" ||
input.event.type === "provider.updated"
) {
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh()
return
}
if (input.event.type !== "project.updated") return
const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject)
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
if (result.found) {
input.setGlobalProject(
@@ -120,11 +60,7 @@ export function applyGlobalEvent(input: {
)
}
function cleanupSessionCaches(
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
) {
function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
if (!sessionID) return
setStore(
produce((draft) => {
@@ -133,12 +69,7 @@ function cleanupSessionCaches(
)
}
export function cleanupDroppedSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
next: AppSession[],
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
) {
export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
const keep = new Set(next.map((item) => item.id))
const stale = [
...Object.keys(store.message),
@@ -159,7 +90,7 @@ export function cleanupDroppedSessionCaches(
}
export function applyDirectoryEvent(input: {
event: AppEvent | DirectoryLegacyEvent
event: { type: string; properties?: unknown }
store: Store<State>
setStore: SetStoreFunction<State>
push: (directory: string) => void
@@ -167,17 +98,11 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
loadReferences?: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
retainedLimit?: number
sessionContent?: boolean
permission?: State["permission"]
}) {
if (input.event.type === "server.instance.disposed") {
input.push(input.directory)
return
}
const event = normalizeDirectoryEvent(input.event)
if (!event) return
const event = input.event
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
@@ -186,7 +111,7 @@ export function applyDirectoryEvent(input: {
return
}
case "session.created": {
const info = (event.properties as { info: AppSession }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore("session", result.index, reconcile(info))
@@ -201,7 +126,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.updated": {
const info = (event.properties as { info: AppSession }).info
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (info.time.archived) {
if (input.store.session[result.index]!.time.archived === info.time.archived) break
@@ -230,10 +155,8 @@ export function applyDirectoryEvent(input: {
break
}
case "session.deleted": {
const properties = event.properties as { sessionID?: string; info?: { id: string } }
const sessionID = properties.sessionID ?? properties.info?.id ?? ""
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
const info = result.found ? input.store.session[result.index] : undefined
const info = (event.properties as { info: Session }).info
const result = Binary.search(input.store.session, info.id, (s) => s.id)
if (result.found) {
input.setStore(
"session",
@@ -242,29 +165,23 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
if (info?.parentID) break
cleanupSessionCaches(input.setStore, info.id)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: AppTodo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionActivity }
const props = event.properties as { sessionID: string; status: SessionStatus }
input.setStore("session_status", props.sessionID, reconcile(props.status))
break
}
case "message.updated": {
const info = clean((event.properties as { info: AppMessage }).info)
const info = clean((event.properties as { info: Message }).info)
const messages = input.store.message[info.sessionID]
if (!messages) {
input.setStore("message", info.sessionID, [info])
@@ -305,7 +222,7 @@ export function applyDirectoryEvent(input: {
break
}
case "message.part.updated": {
const part = (event.properties as { part: AppPart }).part
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
input.setStore(
produce((draft) => {
@@ -389,7 +306,7 @@ export function applyDirectoryEvent(input: {
break
}
case "permission.asked": {
const permission = event.properties as AppPermissionRequest
const permission = event.properties as PermissionRequest
const permissions = input.store.permission[permission.sessionID]
if (!permissions) {
input.setStore("permission", permission.sessionID, [permission])
@@ -425,7 +342,7 @@ export function applyDirectoryEvent(input: {
break
}
case "question.asked": {
const question = event.properties as AppQuestionRequest
const question = event.properties as QuestionRequest
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
+2 -3
View File
@@ -1,7 +1,7 @@
import type { AppMcpStatus } from "../backend"
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
export async function toggleMcp(input: {
status: AppMcpStatus["status"]
status: McpStatus["status"]
connect: () => Promise<void>
disconnect: () => Promise<void>
authenticate: () => Promise<void>
@@ -9,7 +9,6 @@ export async function toggleMcp(input: {
}) {
await {
connected: input.disconnect,
pending: async () => {},
needs_auth: input.authenticate,
disabled: input.connect,
failed: input.connect,
@@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import type {
AppMessage as Message,
AppPart as Part,
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppFileDiff as SnapshotFileDiff,
SessionActivity as SessionStatus,
} from "../backend"
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
@@ -32,8 +32,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, unknown>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -42,7 +41,6 @@ describe("app session cache", () => {
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
todo: {},
message: {},
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
@@ -65,8 +63,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, unknown>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -75,7 +72,6 @@ describe("app session cache", () => {
} = {
session_status: {},
session_diff: {},
todo: {},
message: { ses_1: [m] },
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
@@ -89,21 +85,6 @@ describe("app session cache", () => {
expect(store.part[m.id]).toBeUndefined()
})
test("dropSessionCaches accepts V2 stores without todo caches", () => {
const store = {
session_status: { ses_1: { type: "running" } },
session_diff: { ses_1: [] },
message: { ses_1: [] },
part: {},
permission: { ses_1: [] },
question: { ses_1: [] },
part_text_accum_delta: {},
}
expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow()
expect(store.session_status.ses_1).toBeUndefined()
})
test("pickSessionCacheEvictions preserves requested sessions", () => {
const seen = new Set(["ses_1", "ses_2", "ses_3"])
@@ -1,13 +1,21 @@
import type {
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, unknown>
session_diff: Record<string, unknown>
todo?: Record<string, unknown>
message: Record<string, readonly { id: string }[] | undefined>
part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
permission: Record<string, unknown>
question: Record<string, unknown>
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
@@ -27,7 +35,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const sessionID of stale) {
delete store.message[sessionID]
delete store.session_diff[sessionID]
delete store.todo?.[sessionID]
delete store.session_status[sessionID]
delete store.permission[sessionID]
delete store.question[sessionID]
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppSession as Session } from "../backend"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -43,7 +43,7 @@ describe("trimSessions", () => {
const result = trimSessions(list, {
limit: 2,
permission: {
"child-kept-by-permission": [{ id: "perm-1" }],
"child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
},
now,
})
@@ -1,21 +1,21 @@
import type { AppSession } from "../backend"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
export function sessionUpdatedAt(session: AppSession) {
export function sessionUpdatedAt(session: Session) {
return session.time.updated ?? session.time.created
}
export function compareSessionRecent(a: AppSession, b: AppSession) {
export function compareSessionRecent(a: Session, b: Session) {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
}
export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
if (limit <= 0) return [] as AppSession[]
const selected: AppSession[] = []
export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
if (limit <= 0) return [] as Session[]
const selected: Session[] = []
const seen = new Set<string>()
for (const session of sessions) {
if (!session?.id) continue
@@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff
}
export function trimSessions(
input: AppSession[],
options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
input: Session[],
options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
) {
const limit = Math.max(0, options.limit)
const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+39 -53
View File
@@ -1,23 +1,22 @@
import type {
AppMessage,
AppPart,
AppPermissionRequest,
AppQuestionRequest,
AppSession,
AppFileDiff,
AppTodo,
AppCommand,
AppConfig,
AppAgent,
AppLspStatus,
AppMcpResource,
AppMcpStatus,
AppPathInfo,
AppProvider,
AppReference,
AppVcsInfo,
SessionActivity,
} from "../backend"
Agent,
Command,
Config,
LspStatus,
McpResource,
McpStatus,
Message,
Part,
Path,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
Session,
SessionStatus,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -32,62 +31,49 @@ export type ProjectMeta = {
}
}
export type StoreConfig = {
-readonly [Key in keyof AppConfig]: AppConfig[Key]
}
export type ProviderStore = {
all: ReadonlyMap<string, AppProvider>
connected: readonly string[]
default: Readonly<Record<string, string>>
}
export type State = {
status: "loading" | "partial" | "complete"
agent: AppAgent[]
command: readonly AppCommand[]
reference: readonly AppReference[]
agent: Agent[]
command: Command[]
reference: ReferenceInfo[]
project: string
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: ProviderStore
config: StoreConfig
path: AppPathInfo
session: AppSession[]
provider: NormalizedProviderListResponse
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionActivity
[sessionID: string]: SessionStatus
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: AppFileDiff[]
}
todo: {
[sessionID: string]: AppTodo[]
[sessionID: string]: FileDiffInfo[]
}
permission: {
[sessionID: string]: AppPermissionRequest[]
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: AppQuestionRequest[]
[sessionID: string]: QuestionRequest[]
}
mcp_ready: boolean
mcp: {
[name: string]: AppMcpStatus
[name: string]: McpStatus
}
mcp_resource: {
[key: string]: AppMcpResource
[key: string]: McpResource
}
lsp_ready: boolean
lsp: readonly AppLspStatus[]
vcs: AppVcsInfo | undefined
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: AppMessage[]
[sessionID: string]: Message[]
}
part: {
[messageID: string]: AppPart[]
[messageID: string]: Part[]
}
part_text_accum_delta: {
[partID: string]: string
@@ -95,8 +81,8 @@ export type State = {
}
export type VcsCache = {
store: Store<{ value: AppVcsInfo | undefined }>
setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
ready: Accessor<boolean>
}
@@ -141,11 +127,11 @@ export type DisposeCheck = {
export type RootLoadArgs = {
directory: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }>
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
}
export type RootLoadResult = {
data?: AppSession[]
data?: Session[]
limit: number
limited: boolean
}
@@ -1,5 +1,38 @@
import { describe, expect, test } from "bun:test"
import { directoryKey } from "./utils"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { directoryKey, normalizeAgentList } from "./utils"
const agent = (name = "build") =>
({
name,
mode: "primary",
permission: {},
options: {},
}) as Agent
describe("normalizeAgentList", () => {
test("keeps array payloads", () => {
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
})
test("wraps a single agent payload", () => {
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
})
test("extracts agents from keyed objects", () => {
expect(
normalizeAgentList({
build: agent("build"),
docs: agent("docs"),
}),
).toEqual([agent("build"), agent("docs")])
})
test("drops invalid payloads", () => {
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
+36 -1
View File
@@ -1,8 +1,43 @@
import type { AppProject as Project } from "../backend"
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function isAgent(input: unknown): input is Agent {
if (!input || typeof input !== "object") return false
const item = input as { name?: unknown; mode?: unknown }
if (typeof item.name !== "string") return false
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
}
export function normalizeAgentList(input: unknown): Agent[] {
if (Array.isArray(input)) return input.filter(isAgent)
if (isAgent(input)) return [input]
if (!input || typeof input !== "object") return []
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
return {
...input,
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
),
),
}
}
export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
return {
+6 -41
View File
@@ -3,25 +3,20 @@ import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { pathKey } from "@/utils/path-key"
import { useCheckServerHealth, useServerHealth } from "@/utils/server-health"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { usePlatform } from "./platform"
import { backendIdentity, createBackendForServer } from "./backend-client"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const platform = usePlatform()
const checkServerHealth = useCheckServerHealth()
const serverHealth = useServerHealth(
() => server.list,
() => true,
checkServerHealth,
)
const [store, setStore] = createStore({
settings: {
@@ -42,7 +37,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const owner = getOwner()
@@ -50,26 +45,10 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
const identity = backendIdentity(conn)
if (existing?.identity === identity) return existing.serverCtx
if (existing) {
existing.dispose()
serverCtxs.delete(key)
}
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(
conn,
server.scope(key),
server.projects.forServer(key),
createBackendForServer({
server: conn,
browserUrl: location.href,
fetch: platform.fetch ?? globalThis.fetch,
eventFetch: eventStreamFetch(conn.http.url, platform.fetch),
health: checkServerHealth(conn.http),
}),
)
return { dispose, identity, serverCtx }
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
return root.serverCtx
@@ -118,7 +97,6 @@ function createServerCtx(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
backend: ReturnType<typeof createBackendForServer>,
) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -129,7 +107,7 @@ function createServerCtx(
},
},
})
const sdk = createServerSdkContext(conn, scope, backend)
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
function enrich(project: { worktree: string; expanded: boolean }) {
@@ -163,7 +141,6 @@ function createServerCtx(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
backend,
queryClient,
sdk,
sync,
@@ -176,18 +153,6 @@ function createServerCtx(
}
}
function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) {
if (!fetch) return globalThis.fetch
try {
const parsed = new URL(url)
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"
if (parsed.protocol === "http:" && !loopback) return fetch
} catch {
return globalThis.fetch
}
return globalThis.fetch
}
export type ServerCtx = ReturnType<typeof createServerCtx>
function isLocalHost(url: string) {
+3 -8
View File
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import type { AppProject } from "./backend"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
@@ -72,7 +72,7 @@ type TabHandoff = {
at: number
}
export type LocalProject = Partial<AppProject> & { worktree: string; expanded: boolean }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export type ReviewDiffStyle = "unified" | "split"
@@ -550,7 +550,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setColors(worktree, color)
}
if (!project.id) continue
const projectID = project.id
const requested = colorRequested.get(worktree)
if (requested === color) continue
@@ -562,11 +561,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
void serverSdk()
.backend.then((client) => {
const editing = client.capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
return editing.update({ projectID, location: { directory: worktree }, icon: { color } })
})
.client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
+1 -9
View File
@@ -64,15 +64,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const models = useModels()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
sync()
.data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)
.map((item) => ({
...item,
model: item.model && { providerID: item.model.providerID, modelID: item.model.id },
variant: item.model?.variant,
})),
)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
+2 -2
View File
@@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
() =>
new Map(
available().map((model) => {
const parsed = DateTime.fromISO(model.releaseDate ?? "")
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
@@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"])
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
+10 -9
View File
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -31,7 +32,7 @@ type TurnCompleteNotification = NotificationBase & {
type ErrorNotification = NotificationBase & {
type: "error"
error: unknown
error: EventSessionError["properties"]["error"]
}
export type Notification = TurnCompleteNotification | ErrorNotification
@@ -324,7 +325,8 @@ function createServerNotificationState(input: {
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
@@ -351,10 +353,10 @@ function createServerNotificationState(input: {
const handleSessionError = (
directory: string,
event: { sessionID?: string; error?: unknown },
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.sessionID
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
@@ -363,7 +365,7 @@ function createServerNotificationState(input: {
void playSoundById(settings.sounds.errors())
}
const error = event.error
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
@@ -384,13 +386,12 @@ function createServerNotificationState(input: {
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.activity" && event.type !== "session.error") return
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.activity") {
if (event.activity.type !== "idle") return
handleSessionIdle(directory, event.sessionID, time)
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
+10 -20
View File
@@ -1,6 +1,7 @@
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
@@ -20,8 +21,6 @@ type PermissionRespondFn = (input: {
directory?: string
}) => void
type PermissionRequest = { id: string; sessionID: string }
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
@@ -120,17 +119,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
if (!directory) return
serverSDK()
.backend.then((client) =>
client.common.permissions.reply({
sessionID: input.sessionID,
requestID: input.permissionID,
reply: input.response,
location: { directory },
}),
)
.client.permission.respond(input)
.catch(() => {
responded.delete(input.permissionID)
})
@@ -174,9 +164,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const unsubscribe = serverSDK().event.listen((e) => {
const event = e.details
if (event?.type !== "permission.requested") return
if (event?.type !== "permission.asked") return
const perm = event.request
const perm = event.properties
if (!shouldAutoRespond(perm, e.name)) return
respondOnce(perm, e.name)
@@ -192,10 +182,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
)
serverSDK()
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((items) => {
.client.permission.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
for (const perm of items) {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
@@ -224,11 +214,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
)
serverSDK()
.backend.then((client) => client.common.permissions.pending({ location: { directory } }))
.then((items) => {
.client.permission.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of items) {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (!shouldAutoRespond(perm, directory)) continue
respondOnce(perm, directory)
+1 -1
View File
@@ -1,5 +1,5 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { AppFilePartSource as FilePartSource } from "./backend"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
+61 -58
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { AppEvent } from "./backend"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -18,36 +18,34 @@ describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text", partID = "part") => ({
directory: "/repo",
payload: {
type: "timeline.delta",
sessionID: "ses",
itemID: "msg",
contentID: partID,
field,
delta: value,
} as AppEvent,
type: "message.part.delta",
properties: { messageID: "msg", partID, field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const first = delta("hello ")
const second = delta("world")
first.payload.id = "first"
second.payload.id = "second"
const result = coalesceServerEvents([first, second])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ delta: "hello world" })
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent,
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([
"timeline.delta",
"timeline.delta",
"session.activity",
"timeline.delta",
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
})
@@ -55,99 +53,104 @@ describe("coalesceServerEvents", () => {
const first = delta("a")
const other = delta("b", "text", "other")
const last = delta("c")
first.payload.id = "1"
other.payload.id = "2"
last.payload.id = "3"
const result = coalesceServerEvents([first, other, last])
expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"])
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
})
})
describe("enqueueServerEvent", () => {
const partUpdated = (text: string) =>
({
type: "timeline.updated",
item: {
type: "user",
id: "message",
type: "message.part.updated",
properties: {
sessionID: "session",
created: 1,
content: [{ id: "part", type: "text", text }],
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
},
}) as AppEvent
}) as Event
test("preserves part updates across message remove and re-add barriers", () => {
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
enqueue({
type: "timeline.updated",
item: {
type: "user",
created: 1,
content: [],
type: "message.updated",
properties: {
sessionID: "session",
id: "message",
info: {
id: "message",
sessionID: "session",
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
},
})
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"timeline.updated",
"timeline.removed",
"timeline.updated",
"message.part.updated",
"message.removed",
"message.updated",
"message.part.updated",
])
})
test("preserves deltas after a replacement snapshot", () => {
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("a"))
enqueue(partUpdated("ab"))
enqueue({
type: "timeline.delta",
sessionID: "session",
itemID: "message",
contentID: "part",
field: "text",
delta: "c",
})
type: "message.part.delta",
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
} as Event)
const result = coalesceServerEvents(events)
expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
expect(result[1]?.payload).toMatchObject({ delta: "c" })
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
})
test("preserves updates after session deletion", () => {
const events: Array<{ directory: string; payload: AppEvent }> = []
const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
enqueue(partUpdated("old"))
enqueue({
id: "event-delete",
type: "session.deleted",
sessionID: "session",
})
properties: { sessionID: "session" },
} as Event)
enqueue(partUpdated("new"))
expect(events.map((event) => event.payload.type)).toEqual([
"timeline.updated",
"message.part.updated",
"session.deleted",
"timeline.updated",
"message.part.updated",
])
})
test("does not coalesce edge-triggered session statuses", () => {
const events: Array<{ directory: string; payload: AppEvent }> = []
const events: Array<{ directory: string; payload: Event }> = []
const enqueue = (status: "retry" | "busy") =>
enqueueServerEvent(events, {
directory: "/repo",
payload: {
type: "session.activity",
sessionID: "session",
activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
},
type: "session.status",
properties: {
sessionID: "session",
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
},
} as Event,
})
enqueue("retry")
+68 -29
View File
@@ -1,8 +1,9 @@
import type { AppClient, AppEvent } from "./backend"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
@@ -14,15 +15,14 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: AppEvent }
type QueuedServerEvent = { directory: string; payload: Event }
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "timeline.updated") {
return `timeline.updated:${event.directory}:${event.payload.item.id}`
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
}
if (event.payload.type === "timeline.content.updated")
return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
return undefined
}
@@ -40,22 +40,23 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
if (event.payload.type !== "timeline.delta") {
if (event.payload.type !== "message.part.delta") {
output.push(event)
return
}
const props = event.payload.properties
const previous = output[output.length - 1]
if (
!previous ||
previous.payload.type !== "timeline.delta" ||
previous.payload.type !== "message.part.delta" ||
previous.directory !== event.directory ||
previous.payload.itemID !== event.payload.itemID ||
previous.payload.contentID !== event.payload.contentID ||
previous.payload.field !== event.payload.field
previous.payload.properties.messageID !== props.messageID ||
previous.payload.properties.partID !== props.partID ||
previous.payload.properties.field !== props.field
) {
output.push({
directory: event.directory,
payload: { ...event.payload },
payload: { ...event.payload, properties: { ...props } },
})
return
}
@@ -63,7 +64,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
directory: event.directory,
payload: {
...event.payload,
delta: previous.payload.delta + event.payload.delta,
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
},
}
})
@@ -75,7 +76,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@@ -90,8 +91,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
})()
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const emitter = createGlobalEmitter<{
[key: string]: AppEvent
[key: string]: Event
}>()
type Queued = QueuedServerEvent
@@ -168,13 +174,29 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = envelope.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
if (event.payload.type !== "sync") {
const directory = event.directory ?? "global"
const payload = event.payload as Event
if (enqueueServerEvent(queue, { directory, payload })) schedule()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -231,30 +253,39 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
flush()
})
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
server,
scope,
url: server.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
type ServerSDKWithBackend = ServerSDKBase & { backend: Promise<AppClient> }
export type ServerSDK = ServerSDKWithBackend & {
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
export function createServerSdkContext(
server: ServerConnection.Any,
scope: ServerScope,
backend: Promise<AppClient>,
): ServerSDK {
const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
const sdk = createServerSdkContextBase(server, scope)
return Object.assign(sdk, {
ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
})
@@ -278,10 +309,15 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
})
type SDKEventMap = {
[key in AppEvent["type"]]: AppEvent
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
@@ -292,10 +328,13 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend)
return {
scope: serverSDK.scope,
directory,
backend: serverSDK.backend,
client,
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
+9 -177
View File
@@ -1,51 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type {
AppFileDiff,
AppMessage as Message,
AppPart as Part,
AppSession as Session,
AppTodo,
TimelineContent,
TimelineItem,
} from "./backend"
import { createAppClient } from "./backend.test-fixture"
import { createServerSession as createAppServerSession } from "./server-session"
type FixtureClient = {
session: {
get(input: unknown): Promise<{ data: Session }>
messages(input: unknown): MessageResponse | Promise<MessageResponse>
message?(input: unknown): SingleMessageResponse | Promise<SingleMessageResponse>
diff?(input: unknown): Promise<{ data: AppFileDiff[] }>
todo?(input: unknown): Promise<{ data: AppTodo[] }>
}
}
const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) =>
createAppServerSession(
createAppClient({
version: "v1",
common: {
sessions: {
get: async (input) => (await client.session.get(input)).data,
history: async (input) => {
const result = await client.session.messages(input)
return {
items: result.data.map((item) => timelineItem(item.info, item.parts)),
older: result.response.headers.get("x-next-cursor") ?? undefined,
}
},
message: async (input) => {
if (!client.session.message) throw new Error("Message fixture is not configured")
const result = await client.session.message(input)
return timelineItem(result.data.info, result.data.parts)
},
},
},
}),
options,
)
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
const session = (id: string, parentID?: string): Session => ({
id,
@@ -111,81 +67,6 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
function timelineItem(info: Message, parts: Part[]): TimelineItem {
const content = parts.map(toTimelineContent)
if (info.role === "user")
return {
type: "user",
id: info.id,
sessionID: info.sessionID,
created: info.time.created,
content,
agent: info.agent,
model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant },
format: info.format,
summary: info.summary,
system: info.system,
tools: info.tools,
}
return {
type: "assistant",
id: info.id,
sessionID: info.sessionID,
parentID: info.parentID,
created: info.time.created,
completed: info.time.completed,
content,
agent: info.agent,
model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
tokens: info.tokens,
error: info.error,
mode: info.mode,
path: info.path,
cost: info.cost,
structured: info.structured,
finish: info.finish,
summary: info.summary,
}
}
function toTimelineContent(part: Part): TimelineContent {
if (part.type === "agent")
return {
type: part.type,
id: part.id,
name: part.name,
source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
}
if (part.type === "subtask")
return {
...part,
model: part.model && { id: part.model.modelID, providerID: part.model.providerID },
}
if (part.type !== "file") return { ...part }
return {
type: "file",
id: part.id,
uri: part.url,
name: part.filename,
mime: part.mime,
source:
part.source?.type === "resource"
? {
type: part.source.type,
clientName: part.source.clientName,
uri: part.source.uri,
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
}
: part.source && {
type: part.source.type,
path: part.source.path,
name: part.source.type === "symbol" ? part.source.name : undefined,
kind: part.source.type === "symbol" ? part.source.kind : undefined,
text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
},
}
}
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
let index = 0
const requests: unknown[] = []
@@ -200,7 +81,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
return responses[index++]
},
},
} as FixtureClient
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
requested(count: number) {
@@ -233,7 +114,7 @@ function rootMessageClient(
return roots[rootIndex++]
},
},
} as FixtureClient
} as unknown as OpencodeClient
return Object.assign(client, {
requests,
rootRequests,
@@ -271,7 +152,7 @@ function setup(sessions: Record<string, Session>) {
},
diff: async () => ({ data: [] }),
},
} as FixtureClient
} as unknown as OpencodeClient
return { get, messages, store: createServerSession(client) }
}
@@ -282,10 +163,7 @@ describe("server session", () => {
const result = await ctx.store.lineage.resolve("child")
expect(result.root.id).toBe("root")
expect(ctx.get).toEqual([
{ sessionID: "child", location: undefined },
{ sessionID: "root", location: undefined },
])
expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
@@ -316,9 +194,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: user.id, location: { directory: "/repo" } },
])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
})
@@ -402,9 +278,7 @@ describe("server session", () => {
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart])
})
@@ -426,9 +300,7 @@ describe("server session", () => {
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([
{ sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
@@ -764,21 +636,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toEqual([fetched])
})
test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { id: "optimistic", text: "hello" })
const fetched = textPart(message.id, { id: "message:text", text: "hello" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([optimistic])
})
test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse()
const message = userMessage("message")
@@ -1350,7 +1207,6 @@ describe("server session", () => {
await store.history.loadMore("child")
guard.active = false
expect(store.data.message.child).toEqual([older, latest])
})
@@ -1573,28 +1429,4 @@ describe("server session", () => {
expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
})
test("force-resyncs pinned sessions after a stream reconnect", async () => {
const first = userMessage("first")
const second = userMessage("second", { time: { created: 2 } })
let messages = response([{ info: first, parts: [] }])
let requests = 0
const store = createServerSession({
session: {
get: async () => ({ data: session("child") }),
messages: async () => {
requests++
return messages
},
},
})
store.pin("child")
await store.sync("child")
messages = response([{ info: first, parts: [] }, { info: second, parts: [] }])
await store.resync()
expect(requests).toBe(2)
expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"])
})
})
+71 -240
View File
@@ -1,19 +1,15 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type {
AppClient,
AppEvent,
AppFileDiff,
AppMessage,
AppPart,
AppPermissionRequest,
AppQuestionRequest,
AppSession,
AppTodo,
LocationRef,
SessionActivity,
} from "./backend"
import { timelineMessage, timelineParts } from "./backend"
Message,
OpencodeClient,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@@ -22,7 +18,7 @@ import { rootSession } from "@/utils/session-route"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const historyMessagePageSize = 200
@@ -30,15 +26,15 @@ const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
type OptimisticItem = {
message: AppMessage
parts: AppPart[]
confirmedParts?: AppPart[]
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = {
session: AppMessage[]
part: { id: string; part: AppPart[] }[]
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
@@ -63,31 +59,22 @@ type MessageLoadBaseline = Pick<
>
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] }
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: AppPart[] }[] = []
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
if (!result.found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) ?? []
const matched = result.found
? item.parts.flatMap((optimistic) => {
const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic))
return fetched ? [{ optimistic, fetched }] : []
})
const current = part.get(item.message.id)
const confirmed = result.found
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
const confirmed = matched.map((value) => value.optimistic)
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
result.found
? current.map((value) => {
const match = matched.find((item) => item.fetched.id === value.id)
return match ? { ...value, id: match.optimistic.id } : value
})
: merge(item.confirmedParts ?? [], current),
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
@@ -100,14 +87,6 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
}
}
function samePromptPart(a: AppPart, b: AppPart) {
if (a.type !== b.type) return false
if (a.type === "text" && b.type === "text") return a.text === b.text
if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime
if (a.type === "agent" && b.type === "agent") return a.name === b.name
return false
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
@@ -155,22 +134,21 @@ function reconcileFetched<T extends { id: string }>(
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
}
export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
const [data, setData] = createStore({
info: {} as Record<string, AppSession | undefined>,
session_status: {} as Record<string, SessionActivity>,
session_diff: {} as Record<string, AppFileDiff[]>,
todo: {} as Record<string, AppTodo[]>,
permission: {} as Record<string, AppPermissionRequest[]>,
question: {} as Record<string, AppQuestionRequest[]>,
message: {} as Record<string, AppMessage[]>,
part: {} as Record<string, AppPart[]>,
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
return (this.session_status[id]?.type ?? "idle") !== "idle"
},
})
const requests = new Map<string, Promise<AppSession>>()
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -180,7 +158,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
const removedMessages = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const deleteMessageParts = (
cache: { part: Record<string, AppPart[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
) => {
for (const part of cache.part[messageID] ?? []) {
@@ -208,15 +186,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
at: {} as Record<string, number | undefined>,
})
const locations = new Map<string, LocationRef>()
const location = (sessionID: string) => {
const session = data.info[sessionID]
return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined)
}
const remember = (session: AppSession) => {
if (session.location) locations.set(session.id, session.location)
if (session.parentID && session.location) locations.set(session.parentID, session.location)
const remember = (session: Session) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
infoSeen.add(session.id)
@@ -266,13 +236,11 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = Promise.resolve(backend)
.then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
.then((result) => {
if (!result) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result
return remember(result)
})
const request = client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
if (generations.get(sessionID) !== active) return result.data
return remember(result.data)
})
requests.set(sessionID, request)
const cleanup = () => {
if (requests.get(sessionID) === request) requests.delete(sessionID)
@@ -329,7 +297,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
@@ -346,7 +314,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => {
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
@@ -493,38 +461,35 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
)
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(async () => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) })
return client.session.messages({ sessionID, limit, before })
})
const items = response.items
.map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) }))
.filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message)
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)),
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({
id: item.message.id,
part: item.parts.sort((a, b) => cmp(a.id, b.id)),
id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
})),
cursor: response.older,
complete: !response.older,
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.headers.get("x-next-cursor"),
}
}
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
const response = await (options?.retry ?? retry)(async () => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) })
return client.session.message({ sessionID, messageID })
})
const message = timelineMessage(response)
if (!message) throw new Error(`Message not found: ${messageID}`)
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
return {
message: cleanMessage(message),
parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)),
message: cleanMessage(response.data.info),
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
}
}
const replaceMessages = (sessionID: string, messages: AppMessage[]) => {
const replaceMessages = (sessionID: string, messages: Message[]) => {
const messageIDs = new Set(messages.map((message) => message.id))
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
setData("message", sessionID, reconcile(messages, { key: "id" }))
@@ -594,7 +559,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
sessionID: string,
page: MessagePage,
load: MessageLoadState | undefined,
preserveUnfetched: boolean | ((message: AppMessage) => boolean),
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
@@ -644,7 +609,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
let applied = false
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<AppMessage | undefined>(
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined,
)
@@ -665,7 +630,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
const parentIDs = [
...new Set(
page.session.flatMap((message) =>
message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [],
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
),
),
]
@@ -694,7 +659,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
),
}
const preserveUnfetched =
mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
applyMessagePage(
sessionID,
result,
@@ -717,11 +682,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
}
}
const sync = (
sessionID: string,
options?: { force?: boolean; messageLimit?: number; location?: LocationRef },
) => {
if (options?.location) locations.set(sessionID, options.location)
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
@@ -768,7 +729,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return properties.part.sessionID
}
const applyLegacy = (event: { type: string; properties?: unknown }) => {
const apply = (event: { type: string; properties?: unknown }) => {
const eventID = eventSessionID(event)
if (eventID) {
touch(eventID)
@@ -782,16 +743,16 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
}
switch (event.type) {
case "session.created":
remember((event.properties as { info: AppSession }).info)
remember((event.properties as { info: Session }).info)
return
case "session.updated": {
const info = (event.properties as { info: AppSession }).info
const info = (event.properties as { info: Session }).info
remember(info)
if (info.time.archived) evict([info.id])
return
}
case "session.deleted": {
const sessionID = (event.properties as { sessionID: string }).sessionID
const sessionID = (event.properties as { info: Session }).info.id
infoSeen.delete(sessionID)
setData(
"info",
@@ -801,22 +762,17 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: AppTodo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
return
}
case "session.status": {
const props = event.properties as { sessionID: string; status: SessionActivity }
const props = event.properties as { sessionID: string; status: SessionStatus }
setData("session_status", props.sessionID, reconcile(props.status))
return
}
case "message.updated": {
const info = cleanMessage((event.properties as { info: AppMessage }).info)
const info = cleanMessage((event.properties as { info: Message }).info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
@@ -876,7 +832,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return
}
case "message.part.updated": {
const part = (event.properties as { part: AppPart }).part
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID)
@@ -1015,7 +971,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return
}
case "permission.asked": {
const permission = event.properties as AppPermissionRequest
const permission = event.properties as PermissionRequest
const permissions = data.permission[permission.sessionID]
if (!permissions) {
setData("permission", permission.sessionID, [permission])
@@ -1045,7 +1001,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return
}
case "question.asked": {
const question = event.properties as AppQuestionRequest
const question = event.properties as QuestionRequest
const questions = data.question[question.sessionID]
if (!questions) {
setData("question", question.sessionID, [question])
@@ -1077,118 +1033,6 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
}
}
const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => {
if ("properties" in event) {
applyLegacy(event)
return
}
const sessionID =
event.type === "session.created" || event.type === "session.updated"
? event.session.id
: "sessionID" in event && typeof event.sessionID === "string"
? event.sessionID
: event.type === "timeline.updated"
? event.item.sessionID
: undefined
if (sessionID && eventLocation) locations.set(sessionID, eventLocation)
if (event.type === "session.created" || event.type === "session.updated") {
applyLegacy({ type: event.type, properties: { info: event.session } })
return
}
if (event.type === "session.deleted") {
applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } })
return
}
if (event.type === "session.moved") {
const current = data.info[event.sessionID]
if (!current) return
remember({
...current,
location: event.location,
directory: event.location.directory,
workspaceID: event.location.workspaceID,
})
return
}
if (event.type === "session.revert") {
const current = data.info[event.sessionID]
if (current) remember({ ...current, revert: event.revert })
return
}
if (event.type === "session.activity") {
applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } })
if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation)
return
}
if (event.type === "session.diff" || event.type === "todo.updated") {
applyLegacy({ type: event.type, properties: event })
return
}
if (event.type === "timeline.updated") {
const message = timelineMessage(event.item)
if (!message) return
applyLegacy({ type: "message.updated", properties: { info: message } })
timelineParts(event.item).forEach((part) =>
applyLegacy({ type: "message.part.updated", properties: { part } }),
)
return
}
if (event.type === "timeline.content.updated") {
const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID)
if (!message) return
const part = timelineParts({
type: message.role,
id: message.id,
sessionID: message.sessionID,
created: message.time.created,
completed: message.role === "assistant" ? message.time.completed : undefined,
content: [event.content],
})[0]
if (part) applyLegacy({ type: "message.part.updated", properties: { part } })
return
}
if (event.type === "timeline.removed") {
applyLegacy({
type: "message.removed",
properties: { sessionID: event.sessionID, messageID: event.itemID },
})
return
}
if (event.type === "timeline.part.removed") {
applyLegacy({
type: "message.part.removed",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
},
})
return
}
if (event.type === "timeline.delta") {
applyLegacy({
type: "message.part.delta",
properties: {
sessionID: event.sessionID,
messageID: event.itemID,
partID: event.contentID,
field: event.field,
delta: event.delta,
},
})
return
}
if (event.type === "permission.requested") {
applyLegacy({ type: "permission.asked", properties: event.request })
return
}
if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") {
applyLegacy({ type: event.type, properties: event })
return
}
if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request })
}
return {
data,
set: setData,
@@ -1215,7 +1059,7 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
@@ -1278,14 +1122,9 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
return runInflight(inflightDiff, sessionID, () => {
const active = generation(sessionID)
return retry(async () => {
const client = await backend
const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2
if (!capability?.diff) return []
return capability.diff({ sessionID, location: location(sessionID) })
}).then((result) => {
return retry(() => client.session.diff({ sessionID })).then((result) => {
if (generations.get(sessionID) !== active) return
setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" }))
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
})
})
},
@@ -1316,14 +1155,6 @@ export function createServerSession(backend: Promise<AppClient> | AppClient, opt
if (!count || count === 1) pinned.delete(sessionID)
if (count && count > 1) pinned.set(sessionID, count - 1)
},
async resync() {
const sessionIDs = new Set([
...pinned.keys(),
...optimistic.keys(),
...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]),
])
await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true })))
},
apply,
}
}
+70 -95
View File
@@ -1,11 +1,11 @@
import type {
AppClient,
AppConfig,
AppMcpResource,
AppPathInfo,
AppProject,
AppProviderAuthResponse,
} from "./backend"
Config,
McpResource,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@@ -37,6 +37,7 @@ import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
@@ -46,63 +47,53 @@ import { persisted } from "@/utils/persist"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession } from "./server-session"
type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
type GlobalStore = {
ready: boolean
error?: InitError
path: AppPathInfo
project: AppProject[]
provider: ProviderStore
provider_auth: AppProviderAuthResponse
config: StoreConfig
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "mcp"] as const,
queryFn: () =>
backend
.then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? [])
.then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))),
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
queryOptions<Record<string, AppMcpResource>>({
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions<Record<string, McpResource>>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: () =>
backend
.then(
(client) =>
client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] },
)
.then((result) =>
Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
),
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
placeholderData: {},
})
export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
queryFn: async () => {
const client = await backend
return client.capabilities.lsp?.status({ location: { directory } }) ?? []
},
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
function makeQueryOptionsApi(
scope: ServerScope,
serverSDK: () => OpencodeClient,
sdkFor: (dir: PathKey) => OpencodeClient,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, backend),
projects: () => loadProjectsQuery(scope, backend),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
@@ -113,11 +104,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
const sdkFor = (directory: string) => {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
sdkCache.set(key, sdk)
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -160,13 +164,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as AppProject[])
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -176,7 +180,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
backend: serverSDK.backend,
serverSDK: serverSDK.client,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
@@ -191,7 +195,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[]))
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -206,7 +210,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
bootstrapInstance,
})
const session = createServerSession(serverSDK.backend)
const session = createServerSession(serverSDK.client)
const children = createChildStoreManager({
owner,
@@ -219,9 +223,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
onMcp: (directory, setStore) => {
void retry(() =>
serverSDK.backend
.then((client) => client.common.commands.list({ location: { directory } }))
.then((commands) => setStore("command", reconcile(commands))),
sdkFor(directory)
.command.list()
.then((x) => setStore("command", x.data ?? [])),
).catch((err) => {
showToast({
variant: "error",
@@ -234,6 +238,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const key = directoryKey(directory)
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(serverSDK.scope, key)
},
translate: language.t,
@@ -275,12 +280,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
loadRootSessionsWithFallback({
directory,
limit,
list: (query) =>
serverSDK.backend.then((client) =>
client.common.sessions
.list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit })
.then((page) => ({ data: [...page.items] })),
),
list: (query) => serverSDK.client.session.list(query),
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key)
if (!cache) return
const backend = await serverSDK.backend
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
@@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
project: globalStore.project,
provider: globalStore.provider,
},
backend,
sdk,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -375,14 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
session.apply(event, directory === "global" ? undefined : { directory })
if (event.type === "server.connected") void session.resync()
if (event.type === "provider.updated") {
if (!recent) bootstrap.refetch()
if (directory !== "global") queue.push(directory)
return
}
session.apply(event)
if (directory === "global") {
applyGlobalEvent({
@@ -394,7 +387,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
},
setGlobalProject: setProjects,
})
if (event.type === "server.connected" || event.type === "server.disposed") {
if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) {
queue.push(directory)
@@ -464,16 +457,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: async (config: GlobalConfigUpdate) => {
const backend = await serverSDK.backend
const capability = backend.capabilities.configuration
if (!capability) throw new Error("Server does not support configuration updates")
await capability.updateGlobal({
shell: config.shell,
provider: config.provider,
disabledProviders: config.disabledProviders,
})
},
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -500,32 +484,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryOptions: queryOptionsApi,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
refreshProviders: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
await bootstrap.refetch()
},
project: projectApi,
session,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const backend = await serverSDK.backend
const capability = backend.capabilities.mcpControl
if (!capability) throw new Error("Server does not support MCP controls")
const location = { location: { directory: key } }
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
await toggleMcp({
status,
connect: async () => {
await capability.connect({ ...location, name })
await sdk.mcp.connect({ name })
},
disconnect: async () => {
await capability.disconnect({ ...location, name })
await sdk.mcp.disconnect({ name })
},
authenticate: async () => {
await capability.authenticate({ ...location, name })
await sdk.mcp.auth.authenticate({ name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppMessage as Message, AppPart as Part } from "./backend"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
+12 -12
View File
@@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { AppMessage, AppPart } from "./backend"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: AppPart[]) {
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, AppMessage[] | undefined>
part: Record<string, AppPart[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: AppMessage
parts: AppPart[]
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
@@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
}
type OptimisticItem = {
message: AppMessage
parts: AppPart[]
message: Message
parts: Part[]
}
type MessagePage = {
session: AppMessage[]
part: { id: string; part: AppPart[] }[]
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
+3 -3
View File
@@ -1,4 +1,4 @@
import type { AppSession } from "./backend"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) {
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
}
@@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: AppSession) {
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info[key]
+31 -35
View File
@@ -149,7 +149,6 @@ function createWorkspaceTerminalSession(
scope: ServerScopeValue,
legacySessionID?: string,
) {
const location = { directory: sdk.directory }
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
@@ -199,27 +198,23 @@ function createWorkspaceTerminalSession(
})
}
const unsub = sdk.event.on("pty.exited", (event) => {
if (event.type !== "pty.exited") return
removeExited(event.ptyID)
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
})
onCleanup(unsub)
const update = (pty: Partial<LocalPTY> & { id: string }) => {
const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.backend
.then((client) =>
client.common.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
location,
}),
)
client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
@@ -229,24 +224,26 @@ function createWorkspaceTerminalSession(
})
}
const clone = async (id: string) => {
const clone = async (client: DirectorySDK["client"], id: string) => {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const next = await sdk.backend
.then((client) => client.common.pty.create({ title: pty.title, location }))
const next = await client.pty
.create({
title: pty.title,
})
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!next) return
if (!next?.data) return
const active = store.active === pty.id
batch(() => {
setStore("all", index, {
id: next.id,
title: next.title ?? pty.title,
id: next.data.id,
title: next.data.title ?? pty.title,
titleNumber: pty.titleNumber,
buffer: undefined,
cursor: undefined,
@@ -255,7 +252,7 @@ function createWorkspaceTerminalSession(
cols: undefined,
})
if (active) {
setStore("active", next.id)
setStore("active", next.data.id)
}
})
}
@@ -273,14 +270,14 @@ function createWorkspaceTerminalSession(
new() {
const nextNumber = pickNextTerminalNumber()
sdk.backend
.then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
.then((pty) => {
const id = pty.id
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
const newTerminal = {
id,
title: pty.title ?? defaultTitle(nextNumber),
title: pty.data?.title ?? defaultTitle(nextNumber),
titleNumber: nextNumber,
}
setStore("all", store.all.length, newTerminal)
@@ -291,7 +288,7 @@ function createWorkspaceTerminalSession(
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(pty)
update(sdk.client, pty)
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -306,9 +303,10 @@ function createWorkspaceTerminalSession(
})
},
async clone(id: string) {
await clone(id)
await clone(sdk.client, id)
},
bind() {
const client = sdk.client
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -316,10 +314,10 @@ function createWorkspaceTerminalSession(
setStore("all", index, (pty) => trimTerminal(pty))
},
update(pty: Partial<LocalPTY> & { id: string }) {
update(pty)
update(client, pty)
},
async clone(id: string) {
await clone(id)
await clone(client, id)
},
}
},
@@ -355,11 +353,9 @@ function createWorkspaceTerminalSession(
})
}
await sdk.backend
.then((client) => client.common.pty.remove({ ptyID: id, location }))
.catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
@@ -1,9 +1,9 @@
import { expect, test } from "bun:test"
import type { ProviderStore } from "@/context/global-sync/types"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { selectProviderCatalog } from "./provider-catalog"
const catalog = (id: string): ProviderStore => ({
all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
const catalog = (id: string): NormalizedProviderListResponse => ({
all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
connected: [id],
default: { [id]: `${id}-model` },
})
+4 -4
View File
@@ -1,10 +1,10 @@
import type { ProviderStore } from "@/context/global-sync/types"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} }
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
providers: ProviderStore
providers: NormalizedProviderListResponse
}
type ProviderCatalogInput =
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
explicit: false
directory?: string
catalog?: DirectoryCatalog
global: ProviderStore
global: NormalizedProviderListResponse
}
export function selectProviderCatalog(input: ProviderCatalogInput) {
+2 -10
View File
@@ -1,4 +1,4 @@
import type { AppSession as Session } from "@/context/backend"
import type { Session } from "@opencode-ai/sdk/v2/client"
import {
type ComponentProps,
createEffect,
@@ -508,15 +508,7 @@ export function NewHome() {
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
update: async (value) => {
const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session archiving is not supported by this server")
await capability.archive({
location: { directory: value.directory },
sessionID: value.sessionID,
archivedAt: value.time.archived,
})
},
update: (value) => ctx.sdk.client.session.update(value),
remove: () =>
setStore(
produce((draft) => {
+44 -105
View File
@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { AppSession as Session } from "@/context/backend"
import { getFilename } from "@opencode-ai/core/util/path"
import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -66,7 +66,6 @@ import {
errorMessage,
latestRootSession,
sortedRootSessions,
workspaceCopyCreateInput,
} from "./layout/helpers"
import {
collectNewSessionDeepLinks,
@@ -86,11 +85,6 @@ import { SidebarContent } from "./layout/sidebar-shell"
export default function LegacyLayout(props: ParentProps) {
const serverSDK = useServerSDK()
const [backend] = createResource(
() => serverSDK().backend,
(value) => value,
)
const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
createStore({
@@ -404,7 +398,7 @@ export default function LegacyLayout(props: ParentProps) {
WorktreeState.failed(
serverSDK().scope,
e.name,
e.details.message ?? language.t("common.requestFailed"),
e.details.properties?.message ?? language.t("common.requestFailed"),
)
return
}
@@ -414,21 +408,21 @@ export default function LegacyLayout(props: ParentProps) {
e.details?.type === "question.rejected" ||
e.details?.type === "permission.replied"
) {
const props = e.details
const props = e.details.properties as { sessionID: string }
const sessionKey = `${e.name}:${props.sessionID}`
dismissSessionAlert(sessionKey)
return
}
if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const title =
e.details.type === "permission.requested"
e.details.type === "permission.asked"
? language.t("notification.permission.title")
: language.t("notification.question.title")
const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const)
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
const directory = e.name
const props = e.details.request
if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
const props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = serverSync().child(directory, { bootstrap: false })
const session = store.session.find((s) => s.id === props.sessionID)
@@ -437,7 +431,7 @@ export default function LegacyLayout(props: ParentProps) {
const sessionTitle = session?.title ?? language.t("command.session.new")
const projectName = getFilename(directory)
const description =
e.details.type === "permission.requested"
e.details.type === "permission.asked"
? language.t("notification.permission.description", { sessionTitle, projectName })
: language.t("notification.question.description", { sessionTitle, projectName })
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
@@ -447,7 +441,7 @@ export default function LegacyLayout(props: ParentProps) {
if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now)
if (e.details.type === "permission.requested") {
if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) {
void playSoundById(settings.sounds.permissions())
}
@@ -456,7 +450,7 @@ export default function LegacyLayout(props: ParentProps) {
}
}
if (e.details.type === "question.requested") {
if (e.details.type === "question.asked") {
if (settings.notifications.agent()) {
void platform.notify(title, description, href)
}
@@ -880,12 +874,10 @@ export default function LegacyLayout(props: ParentProps) {
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) return
await capability.archive({
location: { directory: session.directory },
await serverSDK().client.session.update({
directory: session.directory,
sessionID: session.id,
archivedAt: Date.now(),
time: { archived: Date.now() },
})
setStore(
produce((draft) => {
@@ -984,7 +976,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("command.session.archive"),
category: language.t("command.category.session"),
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id || !canArchive(),
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) void archiveSession(session)
@@ -1193,15 +1185,8 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await serverSDK()
.backend.then((client) => {
const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location })
if (project?.id && client.capabilities.projectCopiesV2?.directories)
return client.capabilities.projectCopiesV2
.directories({ projectID: project.id, location })
.then((items) => items.map((item) => item.directory))
return []
})
.client.worktree.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
return canOpen(target)
@@ -1246,11 +1231,8 @@ export default function LegacyLayout(props: ParentProps) {
dirs.map(async (item) => ({
path: { directory: item },
session: await serverSDK()
.backend.then((client) =>
client.common.sessions
.list({ location: { directory: item } })
.then((page) => [...page.items]),
)
.client.session.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
})),
),
@@ -1311,9 +1293,7 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const editing = (await serverSDK().backend).capabilities.projectEditing
if (!editing) throw new Error("Project editing is not supported by this server")
await editing.update({ projectID: project.id, location: { directory: project.worktree }, name })
await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
return
}
@@ -1407,15 +1387,8 @@ export default function LegacyLayout(props: ParentProps) {
setBusy(directory, true)
const result = await serverSDK()
.backend.then(async (client) => {
const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory })
const copies = client.capabilities.projectCopiesV2
const project = layout.projects.list().find((item) => item.worktree === root)
if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server")
await copies.remove({ projectID: project.id, location, directory, force: true })
return true
})
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
@@ -1471,12 +1444,9 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await serverSDK()
.backend.then((client) =>
client.common.sessions
.list({ location: { directory } })
.then((page) => [...page.items]),
)
const sessions: Session[] = await serverSDK()
.client.session.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
clearWorkspaceTerminals(
@@ -1486,26 +1456,12 @@ export default function LegacyLayout(props: ParentProps) {
serverSDK().scope,
)
await serverSDK()
.backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
.client.instance.dispose({ directory })
.catch(() => undefined)
const result = await serverSDK()
.backend.then(async (client) => {
const location = { directory: root }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory })
const copies = client.capabilities.projectCopiesV2
const project = layout.projects.list().find((item) => item.worktree === root)
if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server")
await copies.remove({ projectID: project.id, location, directory, force: true })
await copies.create({
projectID: project.id,
location,
strategy: "git_worktree",
directory: getDirectory(directory),
name: getFilename(directory),
})
return true
})
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.reset.failed.title"),
@@ -1526,14 +1482,10 @@ export default function LegacyLayout(props: ParentProps) {
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.backend.then(async (client) => {
const capability = client.capabilities.sessionExtrasV1
if (!capability) return
await capability.archive({
location: { directory: session.directory },
sessionID: session.id,
archivedAt,
})
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: archivedAt },
})
.catch(() => undefined),
),
@@ -1571,8 +1523,9 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((files) => {
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setData({ status: "ready", dirty })
})
@@ -1629,11 +1582,8 @@ export default function LegacyLayout(props: ParentProps) {
const refresh = async () => {
const sessions = await serverSDK()
.backend.then((client) =>
client.common.sessions
.list({ location: { directory: props.directory } })
.then((page) => [...page.items]),
)
.client.session.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
const active = sessions.filter((session) => session.time.archived === undefined)
setState({ sessions: active })
@@ -1641,8 +1591,9 @@ export default function LegacyLayout(props: ParentProps) {
onMount(() => {
serverSDK()
.backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
.then((files) => {
.client.vcs.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
const dirty = files.length > 0
setState({ status: "ready", dirty })
void refresh()
@@ -1871,17 +1822,8 @@ export default function LegacyLayout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await serverSDK()
.backend.then((client) => {
const location = { directory: project.worktree }
if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location })
const copies = client.capabilities.projectCopiesV2
const input = workspaceCopyCreateInput(project)
if (!copies || !input) throw new Error("Project copy creation is not supported by this server")
return copies.create({
...input,
location,
})
})
.client.worktree.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
@@ -1892,8 +1834,7 @@ export default function LegacyLayout(props: ParentProps) {
if (!created?.directory) return
const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined
setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch)
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
const local = project.worktree
const key = pathKey(created.directory)
@@ -1926,7 +1867,6 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive,
workspaceName,
renameWorkspace,
editorOpen,
@@ -1973,7 +1913,6 @@ export default function LegacyLayout(props: ParentProps) {
clearHoverProjectSoon,
prefetchSession,
archiveSession,
canArchive,
},
}
+1 -11
View File
@@ -6,7 +6,7 @@ import {
parseDeepLink,
parseNewSessionDeepLink,
} from "./deep-links"
import type { AppSession as Session } from "@/context/backend"
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
@@ -19,22 +19,12 @@ import {
homeSessionServerStatus,
latestRootSession,
toggleHomeProjectSelection,
workspaceCopyCreateInput,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make
test("builds a v2 git worktree copy request from the project root", () => {
expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({
projectID: "project",
strategy: "git_worktree",
directory: "/repos/",
})
expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined()
})
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
title: "",
+2 -11
View File
@@ -1,5 +1,5 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { AppSession as Session } from "@/context/backend"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
import type { HomeProjectSelection } from "@/context/layout"
@@ -9,15 +9,6 @@ type SessionStore = {
path: { directory: string }
}
export function workspaceCopyCreateInput(project: { id?: string; worktree: string }) {
if (!project.id) return
return {
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(project.worktree),
}
}
function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => {
@@ -1,4 +1,4 @@
import type { AppSession as Session } from "@/context/backend"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -87,7 +87,6 @@ export type SessionItemProps = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
}
const SessionRow = (props: {
@@ -242,7 +241,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
</Show>
</div>
<Show when={!props.level && props.canArchive()}>
<Show when={!props.level}>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import type { AppSession as Session } from "@/context/backend"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout"
import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -42,7 +42,6 @@ export type WorkspaceSidebarContext = {
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
canArchive: Accessor<boolean>
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
editorOpen: (id: string) => boolean
@@ -273,7 +272,6 @@ const WorkspaceSessionList = (props: {
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
canArchive={props.ctx.canArchive}
/>
)}
</For>
+33 -67
View File
@@ -1,10 +1,4 @@
import type {
AppFilePart as FilePart,
AppProject as Project,
AppUserMessage as UserMessage,
AppVcsFileDiff as VcsFileDiff,
} from "@/context/backend"
import { getFilename } from "@opencode-ai/core/util/path"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
@@ -646,7 +640,7 @@ export default function Page() {
const project = sync().project
const vcs = sync().data.vcs
if (project?.vcs === "git") list.push("git")
if (project?.vcs === "git" && vcs?.branch && vcs?.defaultBranch && vcs.branch !== vcs.defaultBranch) {
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
list.push("branch")
}
list.push("turn")
@@ -664,7 +658,7 @@ export default function Page() {
})
const vcsKey = createMemo(
() =>
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.defaultBranch ?? ""] as const,
["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
)
const vcsQuery = createQuery(() => {
const mode = vcsMode()
@@ -676,13 +670,8 @@ export default function Page() {
queryFn: mode
? () =>
sdk()
.backend.then((client) =>
client.capabilities.vcs?.diff({
location: { directory: sdk().directory },
mode: mode === "git" ? "working" : mode,
}) ?? [],
)
.then(list)
.client.vcs.diff({ mode })
.then((result) => list(result.data))
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
@@ -728,13 +717,9 @@ export default function Page() {
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
sdk().backend.then((client) =>
client.capabilities.vcs?.diff({
location: { directory: scope },
mode: mode === "git" ? "working" : mode,
context,
}) ?? [],
),
sdk()
.client.vcs.diff({ mode, directory: scope, context })
.then((result) => result.data ?? []),
})
.then((diffs) => diffs.find((diff) => diff.file === file))
@@ -838,13 +823,10 @@ export default function Page() {
}
const gitMutation = useMutation(() => ({
mutationFn: async () => {
const editing = (await sdk().backend).capabilities.projectEditing
if (!editing) throw new Error("Git initialization is not supported by this server")
return editing.initGit({ location: { directory: sdk().directory } })
},
mutationFn: () => sdk().client.project.initGit(),
onSuccess: (x) => {
upsert(x as Project)
if (!x.data) return
upsert(x.data)
},
onError: (err) => {
showToast({
@@ -909,7 +891,13 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return
if (evt.details.type !== "filesystem.changed") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
@@ -1671,6 +1659,8 @@ export default function Page() {
})
}
const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
const session = target.session.get(sessionID)
if (!session) return
@@ -1701,12 +1691,11 @@ export default function Page() {
setFollowup("failed", input.sessionID, undefined)
const ok = await sendFollowupDraft({
backend: sdk().backend,
client: sdk().client,
sync: sync(),
serverSync: serverSync(),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
commitRevert: !!sync().session.get(input.sessionID)?.revert,
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
@@ -1798,14 +1787,13 @@ export default function Page() {
const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
.backend.then((client) =>
client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
)
.client.session.abort({ sessionID })
.catch(() => {})
: Promise.resolve()
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
const client = sdk().client
const target = sync()
const last = target.session.get(input.sessionID)?.revert
const value = draft(input.messageID)
@@ -1815,18 +1803,10 @@ export default function Page() {
roll(input.sessionID, { messageID: input.messageID }, target)
prompt.set(value)
},
request: () =>
halt(input.sessionID).then(() =>
sdk().backend.then((client) => {
const value = { ...input, location: { directory: sdk().directory } }
if (client.capabilities.sessionExtrasV1)
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2)
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
throw new Error("Session revert is not supported by this server")
}),
),
complete: () => undefined,
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
complete: (result) => {
if (result.data) merge(result.data, target)
},
rollback: () => roll(input.sessionID, last, target),
fail,
})
@@ -1838,6 +1818,7 @@ export default function Page() {
const sessionID = params.id
if (!sessionID) return
const client = sdk().client
const target = sync()
const next = userMessages().find((item) => item.id > id)
const last = target.session.get(sessionID)?.revert
@@ -1854,26 +1835,11 @@ export default function Page() {
},
request: () =>
!next
? halt(sessionID).then(() =>
sdk().backend.then((client) => {
const value = { location: { directory: sdk().directory }, sessionID }
if (client.capabilities.sessionExtrasV1)
return client.capabilities.sessionExtrasV1.clearRevert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(value)
throw new Error("Session revert is not supported by this server")
}),
)
: halt(sessionID).then(() =>
sdk().backend.then((client) => {
const value = { location: { directory: sdk().directory }, sessionID, messageID: next.id }
if (client.capabilities.sessionExtrasV1)
return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
if (client.capabilities.sessionExtrasV2)
return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
throw new Error("Session revert is not supported by this server")
}),
),
complete: () => undefined,
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
complete: (result) => {
if (result.data) merge(result.data, target)
},
rollback: () => roll(sessionID, last, target),
fail,
})
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "@/context/backend"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
@@ -16,12 +12,6 @@ const permission = (id: string, sessionID: string) =>
({
id,
sessionID,
action: "read",
resources: ["*"],
permission: "read",
patterns: ["*"],
always: [],
metadata: {},
}) as PermissionRequest
const question = (id: string, sessionID: string) =>
@@ -1,9 +1,6 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
} from "@/context/backend"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useLanguage } from "@/context/language"
@@ -54,14 +51,7 @@ export function createSessionComposerController() {
setStore("responding", perm.id)
sdk()
.backend.then((client) =>
client.common.permissions.reply({
sessionID: perm.sessionID,
requestID: perm.id,
reply: response,
location: { directory: sdk().directory },
}),
)
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -1,5 +1,5 @@
import { For, Show } from "solid-js"
import type { AppPermissionRequest as PermissionRequest } from "@/context/backend"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { AppQuestionAnswer as QuestionAnswer, AppQuestionRequest as QuestionRequest } from "@/context/backend"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -223,15 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) =>
sdk().backend.then((client) =>
client.common.questions.reply({
sessionID: props.request.sessionID,
requestID: props.request.id,
answers,
location: { directory: sdk().directory },
}),
),
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
onMutate: () => {
props.onSubmit()
},
@@ -243,14 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () =>
sdk().backend.then((client) =>
client.common.questions.reject({
sessionID: props.request.sessionID,
requestID: props.request.id,
location: { directory: sdk().directory },
}),
),
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -1,8 +1,4 @@
import type {
AppPermissionRequest as PermissionRequest,
AppQuestionRequest as QuestionRequest,
AppSession as Session,
} from "@/context/backend"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
function sessionTreeRequest<T>(
session: Session[],
+3 -11
View File
@@ -1,9 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type {
AppFileDiff as FileDiffInfo,
AppVcsFileDiff as VcsFileDiff,
} from "@/context/backend"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -57,13 +54,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const readFile = async (path: string) => {
return sdk()
.backend.then(async (client) => {
const input = { location: { directory: sdk().directory }, path }
if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input)
const content = await client.common.files.read(input)
if (content.kind !== "text") return
return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType }
})
.client.file.read({ path })
.then((x) => x.data)
.catch((error) => {
console.debug("[session-review] failed to read file", { path, error })
return undefined
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { AppUserMessage as UserMessage } from "@/context/backend"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
@@ -1,4 +1,4 @@
import type { AppUserMessage as UserMessage } from "@/context/backend"
import type { UserMessage } from "@opencode-ai/sdk/v2"
type Local = {
session: {
@@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -1,7 +1,6 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
Index,
@@ -47,12 +46,12 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type {
AppAssistantMessage as AssistantMessage,
AppMessage as MessageType,
AppPart as PartType,
AppUserMessage as UserMessage,
} from "@/context/backend"
type ToolPart = Extract<PartType, { type: "tool" }>
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -291,14 +290,7 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const shareUrl = createMemo(() => info()?.share?.url)
const [backend] = createResource(
() => sdk().backend,
(value) => value,
)
const shareEnabled = createMemo(
() => sync().data.config.share !== "disabled" && !!backend()?.capabilities.sessionExtrasV1,
)
const canArchive = createMemo(() => !!backend()?.capabilities.sessionExtrasV1)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const parent = createMemo(() => {
const id = parentID()
@@ -655,22 +647,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session sharing is not supported by this server")
return capability.share({ location: { directory: sdk().directory }, sessionID: id })
},
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: async (id: string) => {
const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
if (!capability) throw new Error("Session sharing is not supported by this server")
return capability.unshare({ location: { directory: sdk().directory }, sessionID: id })
},
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -678,15 +662,7 @@ export function MessageTimeline(props: {
const titleMutation = useMutation(() => ({
mutationFn: (input: { id: string; title: string }) =>
sdk().backend.then((client) => {
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session renaming is not supported by this server")
return capability.rename({
location: { directory: sdk().directory },
sessionID: input.id,
title: input.title,
})
}),
sdk().client.session.update({ sessionID: input.id, title: input.title }),
onSuccess: (_, input) => {
sync().set(
produce((draft) => {
@@ -830,11 +806,7 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.backend.then(async (client) => {
const capability = client.capabilities.sessionExtrasV1
if (!capability) throw new Error("Session archiving is not supported by this server")
await capability.archive({ location: { directory: sdk().directory }, sessionID, archivedAt: Date.now() })
})
.client.session.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
@@ -863,11 +835,8 @@ export function MessageTimeline(props: {
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk()
.backend.then((client) => {
const capability = client.capabilities.sessionActionsV1
if (!capability) throw new Error("Session deletion is not supported by this server")
return capability.remove({ location: { directory: sdk().directory }, sessionID })
})
.client.session.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
@@ -1234,10 +1203,7 @@ export function MessageTimeline(props: {
return (
<TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry
status={((status) => (status.type === "running" ? { type: "busy" as const } : status))(sessionStatus())}
show={activeMessageID() === retryRow().userMessageID}
/>
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
@@ -1583,11 +1549,9 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canArchive()}>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1656,11 +1620,9 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<Show when={canArchive()}>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
AppAssistantMessage as AssistantMessage,
AppMessage as Message,
AppUserMessage as UserMessage,
} from "@/context/backend"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
@@ -1,4 +1,4 @@
import type { AppMessage as Message, AppUserMessage as UserMessage } from "@/context/backend"
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
@@ -1,11 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type {
AppAssistantMessage as AssistantMessage,
AppMessage as Message,
AppPart as Part,
AppUserMessage as UserMessage,
SessionActivity as SessionStatus,
} from "@/context/backend"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { reuseTimelineRows } from "./row-reconciliation"
import { Timeline, TimelineRow } from "./rows"

Some files were not shown because too many files have changed in this diff Show More