Compare commits

..
57 changed files with 376 additions and 5166 deletions
-352
View File
@@ -1,352 +0,0 @@
# Sync Engine Plan (draft)
Redesign of client state management: consume the event-sourced server that
already exists. Goal: **less code, more reliable** — optimism, reconnect, and
retry become structural guarantees instead of defended special cases.
Status: outline for discussion. Sections marked `TODO` get filled in as we talk.
---
## 0. Context ledger (receipts — do not lose these)
Facts established by code audit and prototyping, with locations.
### The server is already event-sourced
- Durable per-session event log in SQLite: `event(aggregate_id, seq, type, data)`
+ `event_sequence` counter, unique index `(aggregate_id, seq)`
(`packages/core/src/event/sql.ts`, migration `20260323234822_events`).
- Event append, projections, and `commit` hooks all run in **one SQLite
transaction** (`packages/core/src/bus.ts:249-370`, `behavior: "immediate"`).
- 40 of 46 session event types are durable; the 6 ephemeral are streaming
deltas/progress/usage ticks (`packages/schema/src/session-event.ts:626`).
Durable events carry complete facts (`text.ended` has full text).
- All hydration tables are written **only** by projectors inside the append
transaction: `SessionMessageTable`, `SessionTable``session/projector.ts`;
`SessionInboxTable``inbox.ts` `project*` fns called only from
`projector.ts:521,555,571,577`. `SessionPendingTable` is dead (zero writers).
- Message rows carry the aggregate seq of their creating event
(`projector.ts:386`), and `Session.messages` already paginates by it
(`session.ts:501`).
- Replay+live log endpoint exists:
`GET /api/experimental/session/:id/log?after=seq&follow=true`
(`packages/protocol/src/groups/session.ts:631`). Only devtools consumes it.
- Idempotent admission ~exists: `SessionInbox.admit` (`inbox.ts:137`) adopts an
existing client-supplied `SessionMessage.ID`, re-checks after conflict
defects. Gap: adopts without comparing payloads (no conflict on mismatch).
- Reverts are committed server-side inside `Session.prompt`
(`core/src/session.ts:567` at audit time) — client-side revert.commit is
already redundant.
### The client ignores all of it
- The client data layer is `packages/client/src/solid/data.ts` (~1,530 lines;
`packages/tui/src/context/data.tsx` is now a 22-line wrapper) — shared by
TUI/desktop/web, so the engine lands once for all clients. Three racing
write paths into one Solid store: a ~60-case bus-event switch, fetch
handlers doing wholesale `reconcile()`, and (in PR #42807) an optimistic
ledger both must consult. `sync.complete(key)` markers dedupe fetches but
are not watermarks.
- Opening a session today = four separate fetches: `session.get`,
`session.list({parentID})` (children), `session.inbox.list`, messages
(`data.ts:1084-1140`) — four sync keys, no shared consistent point.
- Reconnect = `sync.reset()` + refetch everything; no gap proof.
### Prior art
- PRs #42807 (optimistic message send) and #42808 (optimistic session create):
the stopgap. They landed client-generated IDs (keep) and ~15 hand-placed
guard sites defending the optimistic ledger (delete when this plan ships).
- Prototype: `~/code/open-source/sync-proto` — Effect v4 + Solid. Engine core
~200 lines. Six laws as passing tests; seeded 2-client chaos sim (lost
requests, lost responses after durable write, rejections, connection cuts,
latency shifts) with convergence / ordering / uniqueness / no-flicker
invariants; sim caught a real protocol bug (validation-before-adoption) on
first run. Two-pane demo (await vs optimistic) with narrated scenarios.
- Proposal doc: `~/code/open-source/opencode-sync-proposal/PROPOSAL.md`
("Intents, Not Mutations").
### Vocabulary
- **fold** — pure reduction of durable events into state; same meaning on both
sides. **intent** — a user action not yet acknowledged, client ID, in the
**outbox**. **watermark** — the aggregate seq a snapshot reflects.
**echo determinism** — an intent renders byte-identical to the row its
admission event folds to, so acks are invisible.
- The six laws: idempotency, echo determinism, sync opacity, ordering,
convergence, failure atomicity.
### Ordering constraint (the one subtle thing)
Watermark reads must share a transaction with row reads:
`seq`-then-rows double-applies accumulators; rows-then-`seq` loses events.
---
## 1. Problem
TODO — expand together. Sketch:
- Optimism, reconnect, and retry are each defended point-by-point instead of
guaranteed structurally; every new sync path re-fights the same races.
- The client maintains a hand-written mutable mirror of state the server can
already replay deterministically.
## 2. The claim: less code, more reliable
TODO — expand together. Sketch:
- Fold cases ≈ today's event switch (that code stays, becomes pure).
- Deleted: optimistic ledger + guards, echo replace-in-place, sync-survival
merges, `submitTails` throttling, `sync.complete` session keys,
refetch-everything reconnect.
- Added: engine core (~200 lines proven in prototype) + watermark plumbing.
- Reliability from structure: the six laws hold by construction, not vigilance.
## 3. Design
```
state = fold(snapshot, durable events) server truth only
outbox = ordered pending intents client IDs, idempotent, serial resend
overlay = live ephemeral fragments deltas/progress, superseded by durable facts
view = render(state ⊕ outbox ⊕ overlay) derived
```
The entire client surface for one session — two calls, one stream:
```
GET /session/:id/snapshot fetch → { session, children, inbox,
messages: last(N), seq }
GET /session/:id/log?after=seq&follow=true one ordered stream:
replay: durable events seq+1..head (deltas are live-only, absent here)
marker: log.synced "caught up" — first-class engine signal
live: durable + ephemeral session events interleaved, publish order
```
- **Decided:** the log endpoint's follow phase carries ephemeral session
events too (widen its union from `Durable | Synced`). One server-merged
stream per session; the client never merges two event feeds for one
aggregate, so a delta can never precede its own `Started`.
- **Overlay semantics:** `Map<(messageID, ordinal), accumulated>` — never
enters the fold. The durable full-value boundary (`text.ended`, carries
complete text) supersedes and clears the overlay entry in the same atomic
update that folds it — same no-seam trick as an intent leaving the outbox
on ack. Dropped deltas are self-healing by construction. Same mechanism for
all 6 ephemeral types (`tool.progress`/`usage.updated` superseded by
`tool.success|failed`/`step.ended`).
- **Decided (mid-stream reconnect):** accept today's behavior — after
re-hydrate, overlay accumulates from now, so in-flight text may render with
a missing prefix until its `Ended` supersedes. Simplest code, self-healing,
no protocol change. (Optional later polish: if `Started` wasn't observed
live, show a streaming indicator instead of partial text.)
- Overlay entries render only once their base part exists in folded state
(covers the one degenerate case: a live delta arriving during the replay
window).
- **Outbox ack contract: the echo is the only ack.** Intents carry a
client-minted message ID. The HTTP 2xx is a latency hint, never trusted;
the intent leaves the outbox only when the durable event carrying its ID
folds in (same atomic store update — no flicker), or on a typed rejection
(drop + surface). Timeout/disconnect = unknown fate = intent just stays.
On reconnect: tail first, wait for `log.synced` (replay acks anything that
landed), then resend unacked serially — adopt-on-same-ID (`inbox.ts:137`)
makes resend duplicate-free. Lost request and lost response collapse into
the same case ("no echo yet"), which is why no guard machinery survives.
- **Contract: the snapshot is the only entry point.** The engine never replays
a session from genesis; fold's domain is "snapshot + events after its seq."
Forced by `session.forked` — the event carries only `{ parentID, boundary }`
and the projector copies parent rows, which no client fold could reproduce.
Bonus: licenses future log truncation (serve back to the oldest live
snapshot, not seq 0).
- Compaction/revert/fork all fold cleanly under that contract: compaction only
adds (summary message + epoch marker); revert folds exactly what the
projector does (drop messages/inbox at-or-after boundary, clear marker —
`projector.ts:660-690`); fork needs no fold case at all (a forked session's
snapshot already contains the copied messages).
- Reconnect: tail from last seq first; on typed seq-unavailable → snapshot →
resend unacknowledged → tail. Gapless by construction.
- Scope boundary: session aggregate only. Ambient state (catalog, agents,
projects, vcs, worktrees) stays on the existing global bus as-is.
- Render target: existing Solid store shape, so components don't change.
## 4. Server changes (small)
- S1: watermarked hydration — **decided (tentative, pending prior-art review):**
one new endpoint, `GET /api/session/:id/snapshot`, one read transaction:
```
GET /api/session/:id/snapshot?recent=N
→ { session, children, inbox, messages: last(N), seq }
```
`recent` defaults to 200 — today's first page exactly (`data.ts:1136`,
`limit: 200`). Tuning it smaller later is a parameter change, not a design
change.
- Named `snapshot`, not `state`: point-in-time consistency is the contract,
and `state` collides with the engine's `state = fold(...)`.
- One transaction → one watermark. Rejected alternative: adding `seq` to
each existing read — three watermarks force per-slice replay guards in
the engine, and the session row's accumulators (usage sums) double-count
on overlap replay. Server savings reappear as engine complexity, worse.
- `children` are info rows only; child sessions are separate aggregates and
hydrate their own messages/inbox when opened.
- Older history stays on the existing seq-cursored `messages` endpoint,
folded in as inert backfill (IDs dedupe; revert/compaction arrive on the
log).
- Prior art for snapshot+watermark+tail: Discord (`seq`/RESUME), Telegram
(`pts`/`getDifference`), Linear (`lastSyncId`), Postgres logical
replication (exported snapshot + LSN), Kafka/EventStore catch-up
subscriptions, Replicache (cookie — whose launch checklist requires the
watermark "read in the same transaction as the client view data").
Slack's deprecated `rtm.start` is the cautionary tale for unbounded
snapshots (their edge cache cut boot payloads 744×; 42-message pages).
Details: `notes/hydration-prior-art.md`.
- Adopted from prior art:
- `log?after=seq` returns a typed **seq-unavailable error** when the seq
is not servable → client full-rehydrates. Never silently clamp to the
oldest retained event (Discord op 9 / Telegram `differenceTooLong` /
Postgres explicitly warns against clamping).
- **Cursor honesty (S1 follow-up, landed):** `events.persist` defaults to
off (only workerd enables it), so `event` payload rows usually don't
exist even though `event_sequence` always advances. Old behavior:
`after < head` "replayed" nothing from the empty table and marked
synced — silent desync. Now `openLog` proves retained rows cover
`(after, head]` (`Bus.retainedCount`) and returns seq-unavailable
otherwise; the engine re-snapshots. A cursor is a resumption claim and
gets the strict check; a cursorless read remains "what's retained".
Client side, the engine also treats a `log.synced` marker past its
folded seq as a gap → snapshot recovery, closing the residual
in-process race between the head check and the live subscription.
Persistence therefore stays optional: snapshot + live tail + honest
refusal is a complete contract. A short retention window (persist on +
pruning sweep) can drop in later behind the same check to make busy
reconnects replay instead of refetch.
- Resume-first discipline: reconnect tries the tail from the last seq
before re-snapshotting (Discord meters full rehydrates, not resumes).
- Keep the existing `log.synced` caught-up marker as a first-class engine
signal (EventStoreDB added `CaughtUp` after pain; suppress notifications
during replay).
- ~~S2: adopt strictness~~ — dropped. The engine only needs adopt-on-same-ID,
which exists (`inbox.ts:137` + AGENTS.md reconciliation rules). Conflict on
same-ID/different-payload defends against a client bug we never create.
- S3: widen `session.log` follow phase to interleave ephemeral session events,
**opt-in via `?ephemeral=true`** — default stays durable-only, so existing
log consumers (devtools) are untouched. The response union widens either way
(extra variants simply never appear unless requested); replay is durable-only
in both modes since ephemeral events aren't retained. Also add the typed
seq-unavailable error. Later: promote out of experimental.
- S4: filter param on `event.subscribe` (`/api/event` today sends everything
to everyone and fails slow consumers by contract). The ambient stream slims
to: non-session events + session-lifecycle events needed by the session
list for unopened sessions (created/deleted/renamed/moved, usage, execution
status). Exact param shape TBD.
## 5. Delivery: parallel build in this worktree, compare locally
Not a staged production migration — this branch carries a complete alternate
data layer next to the existing one, so both versions can be run and compared
before any adoption decision.
1. Server additions S1S4 (additive; existing clients unaffected).
2. Engine as a new module in `packages/client` (`solid/engine.ts` or similar)
beside `solid/data.ts` — same store shape and consumer-facing API, fed by
snapshot + session stream + outbox instead of fetch + bus + ledger.
`data.ts` is not modified.
3. TUI in this worktree wired to the engine layer.
4. Compare: run this worktree's full stack (`bun run dev`) against stock v2
side by side — submit latency, kill/reconnect behavior, streaming, revert,
forks. Port the six-laws + chaos tests as the regression net.
5. If it wins: adoption (swap the wiring, delete `data.ts` + PR #42807/#42808
guards) is its own decision with two working implementations to diff.
Deletion inventory at adoption: `data.ts` (~1,530 lines — the 64-case bus
switch becomes the pure fold; 4-fetch session open → one snapshot; all
refetch-on-reconnect machinery → nothing), PRs #42807/#42808 never merge
(stopgap ledger avoided entirely), TUI `util/single-flight.ts` + call
sites (submit is idempotent by minted ID), per-callsite await-then-toast
action plumbing → one generic outbox-rejection path. Rendering components
unchanged by design (same store shape).
### Commit-by-commit
Status: Lane A done (`0af27771` snapshot core, `d611b39f` endpoint,
`b21a3c7d` typed seq-unavailable, `74929dd7` ephemeral opt-in, `e6eac3a0`
simplify — S4 pending shape decision). Lane B done through validation +
simplify (`54a2ec54` fold, `0f8831b3` engine, `2c29a743` laws, `354ab6f9`
chaos, `c9ec1385` simplify). Wiring done: `5d70561f` engine-backed data
layer, `48e237d9` TUI wiring, `90760fc6`/`5f63f252` regression fixes (5 of 6
initial TUI failures were real engine-layer gaps — reconnect refresh, queued
lifecycle, pending refresh, synced-gated sync, family-index pollution — all
fixed; 1 obsolete assertion updated with justification). Suites: TUI 729/0,
client green except 3 pre-existing generated-client failures (repro on stock
v2). Remaining: live comparison pass (B7), S4 shape decision.
Two lanes, disjoint packages, parallelizable. Worker sessions execute; the
coordinating session reviews each commit and keeps this doc current. Every
commit typechecks and passes its package tests before landing.
**Lane A — server** (`packages/core`, `packages/protocol`, generated client):
- **Snapshot** (S1)
- `feat(core): Session.snapshot` — service method, one read transaction:
`{ session, children, inbox, messages: last(recent), seq }`. Core tests:
seq consistency under concurrent publish (a write landing between the
transaction's reads must be impossible), empty session, recent windowing.
- `feat(protocol): session.snapshot endpoint` — route + handler + `bun run
generate` from packages/client.
- **Session stream** (S3)
- `feat(core): typed seq-unavailable on session.log` — `after > head` (or
below retention, future) fails typed instead of silently serving. Test
the `after == head` boundary explicitly (Zero's fence-post bug).
- `feat(core): session.log follow includes ephemeral events` — opt-in
`?ephemeral=true` (default durable-only, existing consumers untouched);
widen union (`Durable | Synced` → session events + `Synced`), interleave
live ephemeral session events in publish order + generate. Test: delta
never precedes its Started on one stream; replay phase stays durable-only
in both modes; default mode carries no ephemeral events.
- **Ambient filter** (S4, can trail; nothing in Lane B blocks on it)
- `feat(protocol): event.subscribe filter` — ambient scope.
**Lane B — engine** (`packages/client/src/solid`, `packages/client/test`):
- **Fold**
- `feat(client): session fold` — pure module: durable session events →
session state (messages, inbox, markers). No transport, no store. The
~60-case switch in `data.ts` is the reference for event semantics.
- **Engine core**
- `feat(client): engine core` — outbox (intents, serial resend, ack-on-
fold-in-same-update), overlay (ephemeral fragments, superseded-and-
cleared by durable facts), view derivation, reconnect loop against a
transport interface.
- **Validation**
- `test(client): six laws` — idempotency, echo determinism, sync opacity,
ordering, convergence, failure atomicity — fake in-process transport,
TestClock, no server.
- `test(client): seeded chaos sim` — two clients, lost requests/responses,
rejections, cuts, latency shifts; convergence + no-flicker invariants.
- **Wiring** (waits on Snapshot's generated client)
- `feat(client): engine-backed data layer` — `data.ts`-compatible surface
over the engine, real transport.
- `feat(tui): wire TUI to engine layer` — this worktree only.
- Verification pass: termctrl live comparison vs stock v2; record demo.
Order: Lane A (Snapshot → Session stream → Ambient filter) and Lane B
(Fold → Engine core → Validation) run in parallel; Wiring waits on Snapshot,
then the verification pass waits on Session stream + Wiring.
## 6. Validation
- Port sync-proto's six laws + seeded chaos sim to run against the real engine
with a faked transport. TODO: where these tests live, CI story.
- Explicit boundary test at `seq == snapshot.seq` (empty tail vs purged log) —
Zero shipped a fence-post bug at exactly this boundary (rocicorp/mono#5589).
## 7. Open questions (to resolve together)
- Web app / desktop adoption order after TUI.
- S4 filter shape: type list vs a named "ambient" scope.
## 8. Non-goals
- No server storage migration; no new event types; no runner changes.
- No component/rendering changes; no changes to ambient (non-session) state.
+82 -171
View File
@@ -12,10 +12,10 @@ import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Event } from "@opencode-ai/schema/event"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -144,46 +144,36 @@ export type Endpoint5_5Input = { readonly sessionID: Session.ID }
export type Endpoint5_5Output = Session.Info
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly recent?: number | undefined }
export type Endpoint5_6Output = {
readonly session: Session.Info
readonly children: ReadonlyArray<Session.Info>
readonly inbox: ReadonlyArray<SessionInbox.Info>
readonly messages: ReadonlyArray<SessionMessage.Info>
readonly seq: Event.Seq
}
export type SessionSnapshotOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID }
export type Endpoint5_7Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type Endpoint5_8Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_11Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -194,10 +184,10 @@ export type Endpoint5_13Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -210,19 +200,19 @@ export type Endpoint5_14Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -231,98 +221,97 @@ export type Endpoint5_16Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_16Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_18Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_20Input = {
export type Endpoint5_19Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_20Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_19Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_27Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_29Input = {
export type Endpoint5_28Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_29Output = void
export type Endpoint5_28Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_30Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_31Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_32Input = {
export type Endpoint5_31Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}
export type Endpoint5_32Output =
export type Endpoint5_31Output =
| (
| {
readonly id: Event.ID
@@ -910,97 +899,20 @@ export type Endpoint5_32Output =
}
}
)
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.usage.updated"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly cost: number & Brand.Brand<"Money.USD">
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly ordinal: number
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.delta"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly delta: string
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.progress"
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly metadata: { readonly [x: string]: Schema.Json }
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.delta"
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly text: string }
}
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
export type Endpoint5_34Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_35Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -1009,7 +921,6 @@ export interface SessionApi<E = never> {
readonly export: SessionExportOperation<E>
readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E>
readonly snapshot: SessionSnapshotOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
+77 -88
View File
@@ -86,8 +86,6 @@ import type {
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -357,56 +355,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
preserveEffect<Endpoint5_6Output>()(
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
preserveEffect<Endpoint5_7Output>()(
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -425,8 +415,8 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -447,16 +437,16 @@ const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14I
),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -473,16 +463,16 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
@@ -492,13 +482,13 @@ const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18I
),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -508,19 +498,27 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -528,70 +526,62 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
query: { after: input["after"], follow: input["follow"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
@@ -599,21 +589,21 @@ const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32I
),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -627,29 +617,28 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(raw),
snapshot: Endpoint5_6(raw),
remove: Endpoint5_7(raw),
fork: Endpoint5_8(raw),
switchAgent: Endpoint5_9(raw),
switchModel: Endpoint5_10(raw),
rename: Endpoint5_11(raw),
move: Endpoint5_12(raw),
prompt: Endpoint5_13(raw),
command: Endpoint5_14(raw),
skill: Endpoint5_15(raw),
synthetic: Endpoint5_16(raw),
shell: Endpoint5_17(raw),
compact: Endpoint5_18(raw),
wait: Endpoint5_19(raw),
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
context: Endpoint5_23(raw),
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
generate: Endpoint5_31(raw),
log: Endpoint5_32(raw),
interrupt: Endpoint5_33(raw),
background: Endpoint5_34(raw),
message: Endpoint5_35(raw),
remove: Endpoint5_6(raw),
fork: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw),
rename: Endpoint5_10(raw),
move: Endpoint5_11(raw),
prompt: Endpoint5_12(raw),
command: Endpoint5_13(raw),
skill: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw),
shell: Endpoint5_16(raw),
compact: Endpoint5_17(raw),
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -22,8 +22,6 @@ import type {
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
SessionSnapshotInput,
SessionSnapshotOutput,
SessionRemoveInput,
SessionRemoveOutput,
SessionForkInput,
@@ -528,18 +526,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
snapshot: (input: SessionSnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionSnapshotOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/snapshot`,
query: { recent: input["recent"] },
successStatus: 200,
declaredStatuses: [404, 500, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
request<SessionRemoveOutput>(
{
@@ -869,9 +855,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
query: { after: input["after"], follow: input["follow"] },
successStatus: 200,
declaredStatuses: [404, 409, 401, 400],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
+48 -96
View File
@@ -687,6 +687,51 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type ModelsDevRefreshed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "models-dev.refreshed"
location?: LocationRef
data: {}
}
export type IntegrationUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.updated"
location?: LocationRef
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "catalog.updated"
location?: LocationRef
data: {}
}
export type AgentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "agent.updated"
location?: LocationRef
data: {}
}
export type SessionUsageUpdated = {
id: string
created: number
@@ -741,51 +786,6 @@ export type SessionCompactionDelta = {
data: { sessionID: string; text: string }
}
export type ModelsDevRefreshed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "models-dev.refreshed"
location?: LocationRef
data: {}
}
export type IntegrationUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.updated"
location?: LocationRef
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "catalog.updated"
location?: LocationRef
data: {}
}
export type AgentUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "agent.updated"
location?: LocationRef
data: {}
}
export type FilesystemChanged = {
id: string
created: number
@@ -1986,28 +1986,10 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem =
| SessionEventDurable
| SessionUsageUpdated
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| EventLogSynced
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
export type SessionSnapshotResponse = {
data: {
session: SessionInfo
children: Array<SessionInfo>
inbox: Array<SessionInboxInfo>
messages: Array<SessionMessageInfo>
seq: number
}
}
export type SessionMessagesResponse = {
data: Array<SessionMessageInfo>
cursor: { previous?: string | null; next?: string | null }
@@ -2216,16 +2198,6 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
"_tag" in value &&
value["_tag"] === "InstructionEntryValueTooLargeError"
export type SeqUnavailableError = {
readonly _tag: "SeqUnavailableError"
readonly sessionID: string
readonly after: number
readonly head?: number | undefined
readonly message: string
}
export const isSeqUnavailableError = (value: unknown): value is SeqUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SeqUnavailableError"
export type ProviderNotFoundError = {
readonly _tag: "ProviderNotFoundError"
readonly providerID: string
@@ -3302,13 +3274,6 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
export type SessionGetOutput = { data: SessionInfo }["data"]
export type SessionSnapshotInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly recent?: { readonly recent?: number | undefined }["recent"]
}
export type SessionSnapshotOutput = SessionSnapshotResponse["data"]
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionRemoveOutput = void
@@ -3947,21 +3912,8 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["after"]
readonly follow?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["follow"]
readonly ephemeral?: {
readonly after?: number | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}["ephemeral"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
}
export type SessionLogOutput = SessionLogItem
+1 -2
View File
@@ -75,8 +75,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
return {
error:
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
if (first.value.type !== "server.connected")
-215
View File
@@ -1,215 +0,0 @@
import { batch, onCleanup } from "solid-js"
import { createStore, reconcile, unwrap } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent, SessionPromptInput } from "../promise"
import { isSeqUnavailableError } from "../promise"
import { createData } from "./data"
import type { CreateDataInput } from "./data"
import { Engine } from "./engine/engine"
type SessionApi = Pick<OpenCodeClient["session"], "snapshot" | "log" | "prompt">
const ambientSessionEvents = new Set<OpenCodeEvent["type"]>([
"session.created",
"session.deleted",
"session.renamed",
"session.execution.started",
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
])
export function createEngineTransport(api: () => SessionApi): Engine.SessionTransport {
return {
snapshot(sessionID) {
return api().snapshot({ sessionID, recent: 200 })
},
async *stream(sessionID, after, signal) {
try {
for await (const item of api().log(
{ sessionID, after, follow: true, ephemeral: true },
signal ? { signal } : undefined,
)) {
if (item.type !== "session.forked") yield item
}
} catch (error) {
if (isSeqUnavailableError(error)) throw new Engine.SeqUnavailable()
throw error
}
},
async submit(input) {
try {
await api().prompt({ ...input.request, sessionID: input.sessionID, id: input.id })
} catch (error) {
if (isTypedError(error)) throw new Engine.SubmitRejected(error.message)
throw error
}
},
}
}
export function createEngineData(config: CreateDataInput) {
const legacy = createData({
...config,
event: {
on: config.event.on,
listen(handler) {
return config.event.listen((event) => {
if (event.name.startsWith("session.") && !ambientSessionEvents.has(event.name)) return
handler(event)
})
},
},
})
const [views, setViews] = createStore<Record<string, Engine.SessionView>>({})
const engines = new Map<string, Promise<Engine.SessionEngine>>()
const families = new Set<string>()
const invalidated = new Set<string>()
const failures = new Set<(failure: Engine.IntentFailure) => void>()
const cleanups = new Set<() => void>()
const transport = createEngineTransport(() => config.api().session)
let connected = false
const update = (sessionID: string, view: Engine.SessionView) => {
batch(() => {
setViews(sessionID, reconcile(structuredClone(view)))
const current = legacy.session.get(sessionID)
if (!current || current.time.updated <= view.session.time.updated) {
legacy.session.remember(structuredClone(view.session))
}
if (families.has(sessionID)) {
view.children.forEach((child) => legacy.session.remember(structuredClone(child)))
}
})
}
const ensure = (sessionID: string) => {
const existing = engines.get(sessionID)
if (existing) return existing
const created = Engine.createSessionEngine(sessionID, transport).then((engine) => {
update(sessionID, engine.view())
cleanups.add(engine.subscribe((view) => update(sessionID, view)))
cleanups.add(engine.subscribeFailures((failure) => failures.forEach((listener) => listener(failure))))
return engine
})
engines.set(sessionID, created)
void created.catch(() => engines.delete(sessionID))
return created
}
const sync = async (sessionID: string) => {
const engine = await ensure(sessionID)
if (invalidated.delete(sessionID)) await engine.refresh()
await engine.ready()
}
cleanups.add(
config.event.on("server.connected", () => {
if (!connected) {
connected = true
return
}
engines.forEach((engine) => void engine.then((handle) => handle.refresh()).catch(() => undefined))
}),
)
onCleanup(() => {
cleanups.forEach((cleanup) => cleanup())
engines.forEach((engine) => void engine.then((handle) => handle.stop()))
})
return {
...legacy,
on: config.event.on,
listen: config.event.listen,
session: {
...legacy.session,
async sync(sessionID: string, options?: { readonly children?: boolean }) {
if (options?.children) families.add(sessionID)
await sync(sessionID)
if (!options?.children) return
const view = views[sessionID]
view?.children.forEach((child) => legacy.session.remember(structuredClone(unwrap(child))))
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
status(sessionID: string) {
if (views[sessionID]?.active === "running") return "running"
return legacy.session.status(sessionID)
},
input: {
list(sessionID: string) {
return (
views[sessionID]?.pending.filter((item) => item.type !== "compaction").map((item) => item.id) ??
legacy.session.input.list(sessionID)
)
},
has(sessionID: string, inboxID: string) {
return (
views[sessionID]?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ??
legacy.session.input.has(sessionID, inboxID)
)
},
},
pending: {
list(sessionID: string) {
void ensure(sessionID)
return [...(views[sessionID]?.pending ?? [])]
},
sync(sessionID: string) {
return sync(sessionID)
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
},
message: {
list(sessionID: string) {
void ensure(sessionID)
return [...(views[sessionID]?.messages ?? [])]
},
get(sessionID: string, messageID: string) {
void ensure(sessionID)
return views[sessionID]?.messages.find((message) => message.id === messageID)
},
sync(sessionID: string) {
return sync(sessionID)
},
invalidate(sessionID: string) {
invalidated.add(sessionID)
},
},
async prompt(input: SessionPromptInput) {
return (await ensure(input.sessionID)).submit({
id: input.id ?? undefined,
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
metadata: input.metadata,
delivery: input.delivery,
resume: input.resume,
})
},
failures: {
listen(listener: (failure: Engine.IntentFailure) => void) {
failures.add(listener)
return () => failures.delete(listener)
},
},
},
}
}
function isTypedError(error: unknown): error is { readonly _tag: string; readonly message: string } {
return (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
typeof error._tag === "string" &&
"message" in error &&
typeof error.message === "string"
)
}
export type EngineData = ReturnType<typeof createEngineData>
-440
View File
@@ -1,440 +0,0 @@
import type {
EventLogSynced,
SessionCompactionDelta,
SessionInboxInfo,
SessionInboxItem,
SessionMessageInfo,
SessionPromptInput,
SessionReasoningDelta,
SessionTextDelta,
SessionToolInputDelta,
SessionToolProgress,
SessionUsageUpdated,
} from "../../promise"
import { SessionFold } from "./fold"
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "./fold"
export type EphemeralSessionEvent =
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| SessionUsageUpdated
export type SessionStreamItem = DurableSessionEvent | EphemeralSessionEvent | EventLogSynced
export type Intent = {
readonly id: string
readonly item: Extract<SessionInboxItem, { readonly type: "user" | "synthetic" }>
readonly request: Omit<SessionPromptInput, "sessionID" | "id">
readonly created: number
}
export type SubmitInput = {
readonly id: string
readonly sessionID: string
readonly request: Intent["request"]
}
export type IntentFailure = {
readonly intent: Intent
readonly reason: string
}
export class SubmitRejected extends Error {
readonly _tag = "SubmitRejected"
constructor(readonly reason: string) {
super(reason)
}
}
export class SeqUnavailable extends Error {
readonly _tag = "SeqUnavailable"
}
export interface SessionTransport {
readonly snapshot: (sessionID: string) => Promise<SessionSnapshot>
readonly stream: (sessionID: string, after: number, signal?: AbortSignal) => AsyncIterable<SessionStreamItem>
readonly submit: (input: SubmitInput) => Promise<void>
}
export type SessionView = SessionFoldState & {
readonly pending: ReadonlyArray<SessionInboxInfo>
}
export interface SessionEngine {
readonly sessionID: string
readonly view: () => SessionView
readonly submit: (input: Intent["request"] & { readonly id?: string }) => Intent
readonly subscribe: (listener: (view: SessionView) => void) => () => void
readonly subscribeFailures: (listener: (failure: IntentFailure) => void) => () => void
readonly ready: () => Promise<void>
readonly refresh: () => Promise<void>
readonly settled: () => Promise<void>
readonly stop: () => void
}
export type SessionEngineOptions = {
readonly makeID?: () => string
readonly now?: () => number
readonly reconnect?: () => Promise<void>
}
type Overlay = ReadonlyMap<string, OverlayEntry>
type OverlayEntry =
| { readonly type: "text"; readonly value: string }
| { readonly type: "reasoning"; readonly value: string }
| { readonly type: "tool-input"; readonly value: string }
| { readonly type: "tool-progress"; readonly metadata: SessionToolProgress["data"]["metadata"] }
| { readonly type: "compaction"; readonly value: string }
| { readonly type: "usage"; readonly value: SessionUsageUpdated["data"] }
type EngineState = {
readonly folded: SessionFoldState
readonly outbox: ReadonlyArray<Intent>
readonly overlay: Overlay
readonly synced: boolean
}
export async function createSessionEngine(
sessionID: string,
transport: SessionTransport,
options: SessionEngineOptions = {},
): Promise<SessionEngine> {
let counter = 0
const makeID = options.makeID ?? (() => `msg_${Date.now().toString(36)}_${++counter}`)
const now = options.now ?? Date.now
const reconnect = options.reconnect ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
let state: EngineState = {
folded: SessionFold.fromSnapshot(await transport.snapshot(sessionID)),
outbox: [],
overlay: new Map(),
synced: false,
}
const listeners = new Set<(view: SessionView) => void>()
const failureListeners = new Set<(failure: IntentFailure) => void>()
const settled = new Set<() => void>()
const ready = Promise.withResolvers<void>()
let sent: string | undefined
let stopped = false
let sending = false
let refreshing: Promise<void> | undefined
const abort = new AbortController()
const publish = (next: EngineState) => {
state = next
const view = render(state)
listeners.forEach((listener) => listener(view))
if (state.outbox.length > 0) return
settled.forEach((resolve) => resolve())
settled.clear()
}
const applySnapshot = (snapshot: SessionSnapshot, synced = false) => {
const folded = SessionFold.fromSnapshot(snapshot)
const acknowledged = new Set([
...folded.messages.map((message) => message.id),
...folded.inbox.map((item) => item.id),
])
publish({
folded,
outbox: state.outbox.filter((intent) => !acknowledged.has(intent.id)),
overlay: new Map(),
synced,
})
}
const applyDurable = (event: DurableSessionEvent) => {
if (event.type === "session.inbox.enqueued" && sent === event.data.inboxID) sent = undefined
publish({
folded: SessionFold.apply(state.folded, event),
outbox:
event.type === "session.inbox.enqueued"
? state.outbox.filter((intent) => intent.id !== event.data.inboxID)
: state.outbox,
overlay: clearOverlay(state.overlay, event),
synced: state.synced,
})
send()
}
const reject = (intent: Intent, reason: string) => {
publish({ ...state, outbox: state.outbox.filter((item) => item.id !== intent.id) })
failureListeners.forEach((listener) => listener({ intent, reason }))
}
const send = () => {
if (!state.synced || sending || stopped) return
const intent = state.outbox[0]
if (!intent || sent === intent.id) return
sending = true
sent = intent.id
void (async () => {
try {
await transport.submit({ id: intent.id, sessionID, request: intent.request })
} catch (error) {
if (!(error instanceof SubmitRejected)) return
sent = undefined
reject(intent, error.reason)
}
})().finally(() => {
sending = false
send()
})
}
const sync = async () => {
while (!stopped) {
try {
for await (const item of transport.stream(sessionID, state.folded.seq, abort.signal)) {
if (stopped) return
if (item.type === "log.synced") {
// A marker past the fold means the server skipped events it could not
// replay for this cursor; recover through a fresh snapshot.
if (item.seq !== undefined && item.seq > state.folded.seq) throw new SeqUnavailable()
sent = undefined
publish({ ...state, synced: true })
ready.resolve()
send()
continue
}
if ("durable" in item) {
applyDurable(item)
continue
}
publish({ ...state, overlay: applyOverlay(state.overlay, item) })
}
} catch (error) {
if (error instanceof SeqUnavailable) {
try {
applySnapshot(await transport.snapshot(sessionID))
} catch {
await reconnect()
}
continue
}
}
if (stopped) return
publish({ ...state, synced: false })
await reconnect()
}
}
void sync()
return {
sessionID,
view: () => render(state),
submit(input) {
const intent: Intent = {
id: input.id ?? makeID(),
created: now(),
request: input,
item: {
type: "user",
delivery: input.delivery ?? "steer",
payload: {
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
},
},
}
publish({ ...state, outbox: [...state.outbox, intent] })
send()
return intent
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
subscribeFailures(listener) {
failureListeners.add(listener)
return () => failureListeners.delete(listener)
},
ready: () => ready.promise,
refresh() {
if (refreshing) return refreshing
refreshing = transport
.snapshot(sessionID)
.then((snapshot) => {
if (snapshot.seq < state.folded.seq) return
applySnapshot(snapshot, state.synced)
send()
})
.finally(() => {
refreshing = undefined
})
return refreshing
},
settled() {
if (state.outbox.length === 0) return Promise.resolve()
return new Promise<void>((resolve) => settled.add(resolve))
},
stop() {
stopped = true
abort.abort()
publish({ ...state, synced: false })
},
}
}
export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">): SessionView {
const messageIDs = new Set(state.folded.messages.map((message) => message.id))
const pending = [
...state.folded.inbox,
...state.outbox.map(
(intent): SessionInboxInfo => ({
id: intent.id,
sessionID: state.folded.session.id,
timeCreated: intent.created,
...intent.item,
}),
),
]
const messages = applyOverlayToMessages(
[
...state.folded.messages,
...pending.flatMap((item): ReadonlyArray<SessionMessageInfo> => {
if (item.type !== "compaction" && item.delivery === "queue") return []
if (messageIDs.has(item.id)) return []
const message = SessionFold.messageFromInbox(item)
return message ? [message] : []
}),
],
state.overlay,
)
const usage = state.overlay.get("usage")
return {
...state.folded,
session:
usage?.type === "usage"
? { ...state.folded.session, cost: usage.value.cost, tokens: usage.value.tokens }
: state.folded.session,
messages,
pending,
}
}
function applyOverlay(overlay: Overlay, event: EphemeralSessionEvent): Overlay {
const next = new Map(overlay)
switch (event.type) {
case "session.text.delta": {
const key = partKey("text", event.data.assistantMessageID, event.data.ordinal)
const current = next.get(key)
next.set(key, {
type: "text",
value: (current?.type === "text" ? current.value : "") + event.data.delta,
})
return next
}
case "session.reasoning.delta": {
const key = partKey("reasoning", event.data.assistantMessageID, event.data.ordinal)
const current = next.get(key)
next.set(key, {
type: "reasoning",
value: (current?.type === "reasoning" ? current.value : "") + event.data.delta,
})
return next
}
case "session.tool.input.delta": {
const key = toolKey("tool-input", event.data.assistantMessageID, event.data.id)
const current = next.get(key)
next.set(key, {
type: "tool-input",
value: (current?.type === "tool-input" ? current.value : "") + event.data.delta,
})
return next
}
case "session.tool.progress":
next.set(toolKey("tool-progress", event.data.assistantMessageID, event.data.id), {
type: "tool-progress",
metadata: event.data.metadata,
})
return next
case "session.compaction.delta": {
const current = next.get("compaction")
next.set("compaction", {
type: "compaction",
value: (current?.type === "compaction" ? current.value : "") + event.data.text,
})
return next
}
case "session.usage.updated":
next.set("usage", { type: "usage", value: event.data })
return next
}
}
function clearOverlay(overlay: Overlay, event: DurableSessionEvent): Overlay {
switch (event.type) {
case "session.text.ended":
return removeOverlay(overlay, partKey("text", event.data.assistantMessageID, event.data.ordinal))
case "session.reasoning.ended":
return removeOverlay(overlay, partKey("reasoning", event.data.assistantMessageID, event.data.ordinal))
case "session.tool.input.ended":
case "session.tool.called":
return removeOverlay(overlay, toolKey("tool-input", event.data.assistantMessageID, event.data.id))
case "session.tool.success":
case "session.tool.failed":
return removeOverlay(overlay, toolKey("tool-progress", event.data.assistantMessageID, event.data.id))
case "session.compaction.ended":
case "session.compaction.failed":
return removeOverlay(overlay, "compaction")
case "session.step.ended":
case "session.step.failed":
case "session.usage.recorded":
return removeOverlay(overlay, "usage")
default:
return overlay
}
}
function removeOverlay(overlay: Overlay, key: string): Overlay {
if (!overlay.has(key)) return overlay
const next = new Map(overlay)
next.delete(key)
return next
}
function applyOverlayToMessages(messages: ReadonlyArray<SessionMessageInfo>, overlay: Overlay) {
return messages.map((message): SessionMessageInfo => {
if (message.type === "compaction" && message.status === "running") {
const entry = overlay.get("compaction")
return entry?.type === "compaction" ? { ...message, summary: message.summary + entry.value } : message
}
if (message.type !== "assistant") return message
const ordinals = { text: 0, reasoning: 0 }
const content = message.content.map((part) => {
if (part.type === "text") {
const entry = overlay.get(partKey("text", message.id, ordinals.text++))
return entry?.type === "text" ? { ...part, text: part.text + entry.value } : part
}
if (part.type === "reasoning") {
const entry = overlay.get(partKey("reasoning", message.id, ordinals.reasoning++))
return entry?.type === "reasoning" ? { ...part, text: part.text + entry.value } : part
}
const input = overlay.get(toolKey("tool-input", message.id, part.id))
if (input?.type === "tool-input" && part.state.status === "streaming")
return { ...part, state: { ...part.state, input: part.state.input + input.value } }
const progress = overlay.get(toolKey("tool-progress", message.id, part.id))
if (progress?.type === "tool-progress" && part.state.status === "running")
return { ...part, state: { ...part.state, metadata: progress.metadata } }
return part
})
return content.some((part, index) => part !== message.content[index]) ? { ...message, content } : message
})
}
function partKey(type: "text" | "reasoning", messageID: string, ordinal: number) {
return `${type}:${messageID}:${ordinal}`
}
function toolKey(type: "tool-input" | "tool-progress", messageID: string, toolID: string) {
return `${type}:${messageID}:${toolID}`
}
export * as Engine from "./engine"
-580
View File
@@ -1,580 +0,0 @@
import type {
SessionEventDurable,
SessionInboxInfo,
SessionInfo,
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
TokenUsageInfo,
} from "../../promise"
export type SessionFoldState = {
readonly session: SessionInfo
readonly children: ReadonlyArray<SessionInfo>
readonly inbox: ReadonlyArray<SessionInboxInfo>
readonly messages: ReadonlyArray<SessionMessageInfo>
readonly active: "idle" | "running"
readonly deleted: boolean
readonly seq: number
}
export type SessionSnapshot = Omit<SessionFoldState, "active" | "deleted"> & {
readonly active?: SessionFoldState["active"]
}
export type DurableSessionEvent = Exclude<SessionEventDurable, { readonly type: "session.forked" }>
export function fromSnapshot(snapshot: SessionSnapshot): SessionFoldState {
return { ...snapshot, active: snapshot.active ?? "idle", deleted: false }
}
export function apply(state: SessionFoldState, event: DurableSessionEvent): SessionFoldState {
if (event.durable.seq <= state.seq) return state
const current = { ...state, seq: event.durable.seq }
switch (event.type) {
case "session.created":
return current
case "session.deleted":
return { ...current, deleted: true }
case "session.usage.recorded":
return { ...current, session: addUsage(state.session, event.data.cost, event.data.tokens, event.created) }
case "session.agent.selected":
return append(
{
...current,
session: {
...state.session,
agent: event.data.agent,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "agent-switched",
agent: event.data.agent,
previous: event.data.previous ?? state.session.agent,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.model.selected":
return append(
{
...current,
session: {
...state.session,
model: event.data.model,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "model-switched",
model: event.data.model,
previous: event.data.previous ?? state.session.model,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.moved":
return append(
{
...current,
session: {
...state.session,
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
time: { ...state.session.time, updated: event.created },
},
},
{
id: messageID(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: {
location: state.session.location,
projectID: state.session.projectID,
subpath: state.session.subpath,
},
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.renamed":
return {
...current,
session: { ...state.session, title: event.data.title, time: { ...state.session.time, updated: event.created } },
}
case "session.inbox.enqueued":
return {
...current,
session: { ...state.session, time: { ...state.session.time, updated: event.created } },
inbox: state.inbox.some((item) => item.id === event.data.inboxID)
? state.inbox
: [
...state.inbox,
{
id: event.data.inboxID,
sessionID: event.data.sessionID,
timeCreated: event.created,
...event.data.item,
},
],
}
case "session.inbox.delivered": {
const item = state.inbox.find((item) => item.id === event.data.inboxID)
const next = { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
if (!item) return next
const delivered = messageFromInbox(item, event.created)
return delivered ? append(next, delivered) : next
}
case "session.inbox.cancelled":
return { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
case "session.inbox.delivery.changed":
return {
...current,
inbox: state.inbox.map((item) =>
item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item,
),
}
case "session.execution.started":
return { ...current, active: "running" }
case "session.execution.succeeded":
case "session.execution.failed":
case "session.execution.interrupted":
return { ...updateActiveAssistant(current, (message) => ({ ...message, retry: undefined })), active: "idle" }
case "session.instructions.updated":
if (event.data.text === undefined) return current
return append(current, {
id: messageID(event.id),
type: "system",
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
case "session.synthetic":
return append(current, {
id: messageID(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
case "session.skill.activated":
return append(current, {
id: messageID(event.id),
type: "skill",
skill: event.data.id,
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
})
case "session.shell.started":
return append(current, {
id: messageID(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
metadata: event.metadata,
time: { created: event.created },
})
case "session.shell.ended":
return updateMessage(
current,
(message) => message.type === "shell" && message.shellID === event.data.shell.id,
(message) => {
if (message.type !== "shell") return message
return {
...message,
status: event.data.shell.status,
exit: event.data.shell.exit,
output: event.data.output,
time: { ...message.time, completed: event.created },
}
},
true,
)
case "session.step.started": {
const existing = state.messages.some((message) => message.id === event.data.assistantMessageID)
if (existing)
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
agent: event.data.agent,
model: event.data.model,
retry: undefined,
error: undefined,
finish: undefined,
time: { ...message.time, completed: undefined },
snapshot: event.data.snapshot ? { ...message.snapshot, start: event.data.snapshot } : message.snapshot,
}))
return append(
updateActiveAssistant(current, (message) => ({
...message,
retry: undefined,
time: { ...message.time, completed: event.created },
})),
{
id: event.data.assistantMessageID,
type: "assistant",
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
},
)
}
case "session.step.ended":
return withUsage(
updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
finish: event.data.finish,
cost: event.data.cost,
tokens: event.data.tokens,
time: { ...message.time, completed: event.created },
snapshot:
event.data.snapshot || event.data.files
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
: message.snapshot,
})),
event.data.cost,
event.data.tokens,
event.created,
)
case "session.step.failed": {
const failed = updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
finish: "error",
error: event.data.error,
retry: undefined,
cost: event.data.cost ?? message.cost,
tokens: event.data.tokens ?? message.tokens,
time: { ...message.time, completed: event.created },
snapshot:
event.data.snapshot || event.data.files
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
: message.snapshot,
}))
if (event.data.cost === undefined || event.data.tokens === undefined) return failed
return withUsage(failed, event.data.cost, event.data.tokens, event.created)
}
case "session.text.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: insertOrdinal(message.content, "text", event.data.ordinal, { type: "text", text: "" }),
}))
case "session.text.ended":
return updateContent(current, event.data.assistantMessageID, "text", event.data.ordinal, (part) => ({
...part,
text: event.data.text,
state: event.data.state,
}))
case "session.reasoning.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: insertOrdinal(message.content, "reasoning", event.data.ordinal, {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
}),
}))
case "session.reasoning.ended":
return updateContent(current, event.data.assistantMessageID, "reasoning", event.data.ordinal, (part) => ({
...part,
text: event.data.text,
state: event.data.state ?? part.state,
time: { created: part.time?.created ?? event.created, completed: event.created },
}))
case "session.tool.input.started":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
content: [
...message.content,
{
type: "tool",
id: event.data.id,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
},
],
}))
case "session.tool.input.ended":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) =>
part.state.status === "streaming" ? { ...part, state: { ...part.state, input: event.data.text } } : part,
)
case "session.tool.called":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => ({
...part,
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, metadata: {} },
time: { ...part.time, ran: event.created },
}))
case "session.tool.success":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
if (part.state.status !== "running") return part
return {
...part,
executed: event.data.executed || part.executed === true,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: part.state.input,
content: event.data.content,
metadata: event.data.metadata,
},
time: { ...part.time, completed: event.created },
}
})
case "session.tool.failed":
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
if (part.state.status !== "streaming" && part.state.status !== "running") return part
return {
...part,
executed: event.data.executed || part.executed === true,
providerResultState: event.data.resultState,
state: {
status: "error",
error: event.data.error,
input: typeof part.state.input === "string" ? {} : part.state.input,
content: event.data.content,
metadata: event.data.metadata,
},
time: { ...part.time, completed: event.created },
}
})
case "session.retry.scheduled":
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
...message,
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
}))
case "session.compaction.started":
return append(
{
...current,
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
},
{
id: event.data.inputID ?? messageID(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent,
metadata: event.metadata,
time: { created: event.created },
},
)
case "session.compaction.ended": {
const running = state.messages.findLast(
(message) => message.type === "compaction" && message.status === "running",
)
if (!running)
return append(current, {
id: messageID(event.id),
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
metadata: event.metadata,
time: { created: event.created },
})
return updateMessage(
current,
(message) => message.id === running.id,
(message) => ({
...message,
type: "compaction",
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
}),
)
}
case "session.compaction.failed": {
const running = state.messages.findLast(
(message) => message.type === "compaction" && message.status === "running",
)
const failed = {
id: running?.id ?? event.data.inputID ?? messageID(event.id),
type: "compaction" as const,
status: "failed" as const,
reason: event.data.reason,
error: event.data.error,
metadata: running?.metadata ?? event.metadata,
time: running?.time ?? { created: event.created },
}
const next = {
...current,
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
}
return running
? updateMessage(
next,
(message) => message.id === running.id,
() => failed,
)
: append(next, failed)
}
case "session.revert.staged":
return {
...current,
session: {
...state.session,
revert: event.data.revert,
time: { ...state.session.time, updated: event.created },
},
}
case "session.revert.cleared":
return {
...current,
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
}
case "session.revert.committed":
return {
...current,
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
messages: state.messages.filter((message) => message.id < event.data.to),
inbox: state.inbox.filter((item) => item.id < event.data.to),
}
}
}
function messageID(eventID: string) {
return eventID.replace(/^evt_/, "msg_")
}
export function messageFromInbox(item: SessionInboxInfo, created = item.timeCreated): SessionMessageInfo | undefined {
if (item.type === "user") return { id: item.id, type: "user", ...item.payload, time: { created } }
if (item.type === "synthetic") return { id: item.id, type: "synthetic", ...item.payload, time: { created } }
}
function append(state: SessionFoldState, item: SessionMessageInfo) {
if (state.messages.some((message) => message.id === item.id)) return state
return { ...state, messages: [...state.messages, item] }
}
function updateMessage(
state: SessionFoldState,
predicate: (message: SessionMessageInfo) => boolean,
update: (message: SessionMessageInfo) => SessionMessageInfo,
last = false,
) {
const index = last ? state.messages.findLastIndex(predicate) : state.messages.findIndex(predicate)
if (index < 0) return state
return {
...state,
messages: state.messages.map((message, position) => (position === index ? update(message) : message)),
}
}
function updateAssistant(
state: SessionFoldState,
messageID: string,
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
) {
return updateMessage(
state,
(message) => message.id === messageID && message.type === "assistant",
(message) => (message.type === "assistant" ? update(message) : message),
)
}
function updateActiveAssistant(
state: SessionFoldState,
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
) {
return updateMessage(
state,
(message) => message.type === "assistant" && message.time.completed === undefined,
(message) => (message.type === "assistant" ? update(message) : message),
true,
)
}
function updateContent<Type extends "text" | "reasoning">(
state: SessionFoldState,
messageID: string,
type: Type,
ordinal: number,
update: (
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) => Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) {
return updateAssistant(state, messageID, (message) => {
const position = message.content.flatMap((part, index) => (part.type === type ? [index] : []))[ordinal]
const part = position === undefined ? undefined : message.content[position]
if (!part || part.type !== type) return message
return {
...message,
content: message.content.map((item, index) =>
index === position
? update(part as Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>)
: item,
),
}
})
}
function updateTool(
state: SessionFoldState,
messageID: string,
toolID: string,
update: (part: SessionMessageAssistantTool) => SessionMessageAssistantTool,
) {
return updateAssistant(state, messageID, (message) => {
const index = message.content.findLastIndex((part) => part.type === "tool" && part.id === toolID)
if (index < 0) return message
return {
...message,
content: message.content.map((part, position) =>
position === index && part.type === "tool" ? update(part) : part,
),
}
})
}
function insertOrdinal<Type extends SessionMessageAssistant["content"][number]["type"]>(
content: SessionMessageAssistant["content"],
type: Type,
ordinal: number,
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
) {
if (content.filter((item) => item.type === type)[ordinal]) return content
return [...content, part]
}
function addUsage(session: SessionInfo, cost: number, tokens: TokenUsageInfo, updated: number): SessionInfo {
return {
...session,
cost: session.cost + cost,
tokens: {
input: session.tokens.input + tokens.input,
output: session.tokens.output + tokens.output,
reasoning: session.tokens.reasoning + tokens.reasoning,
cache: {
read: session.tokens.cache.read + tokens.cache.read,
write: session.tokens.cache.write + tokens.cache.write,
},
},
time: { ...session.time, updated },
}
}
function withUsage(state: SessionFoldState, cost: number, tokens: TokenUsageInfo, updated: number) {
return { ...state, session: addUsage(state.session, cost, tokens, updated) }
}
export * as SessionFold from "./fold"
-1
View File
@@ -1,3 +1,2 @@
export * from "./data"
export * from "./connection"
export * from "./engine-data"
-108
View File
@@ -1,108 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Engine } from "../src/solid/engine/engine"
import { createEngineTransport } from "../src/solid/engine-data"
import { FakeSessionServer } from "./fixture/sync-engine"
describe("engine data transport", () => {
test("uses snapshot and ephemeral follow log contracts", async () => {
const server = new FakeSessionServer("session-transport")
const calls: Array<unknown> = []
const transport = createEngineTransport(() => ({
async snapshot(input) {
calls.push(input)
return server.snapshotValue()
},
async *log(input) {
calls.push(input)
yield { type: "log.synced" as const, aggregateID: input.sessionID, seq: 0 }
},
async prompt() {
throw new Error("unused")
},
}))
expect(await transport.snapshot(server.sessionID)).toEqual(server.snapshotValue())
const items = []
for await (const item of transport.stream(server.sessionID, 0)) items.push(item)
expect(items).toEqual([{ type: "log.synced", aggregateID: server.sessionID, seq: 0 }])
expect(calls).toEqual([
{ sessionID: server.sessionID, recent: 200 },
{ sessionID: server.sessionID, after: 0, follow: true, ephemeral: true },
])
})
test("preserves the prompt request and client-minted ID", async () => {
const requests: Array<unknown> = []
const transport = createEngineTransport(() => ({
async snapshot() {
throw new Error("unused")
},
async *log() {
throw new Error("unused")
},
async prompt(input) {
requests.push(input)
return {
id: input.id!,
sessionID: input.sessionID,
timeCreated: 1,
type: "user",
payload: { text: input.text },
delivery: input.delivery ?? "steer",
}
},
}))
await transport.submit({
id: "msg_client",
sessionID: "session-submit",
request: {
id: undefined,
sessionID: undefined,
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
})
expect(requests).toEqual([
{
id: "msg_client",
sessionID: "session-submit",
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
])
})
test("translates generated typed failures", async () => {
const transport = createEngineTransport(() => ({
async snapshot() {
throw new Error("unused")
},
async *log() {
throw { _tag: "SeqUnavailableError", sessionID: "session", after: 2, head: 1, message: "gone" }
},
async prompt() {
throw { _tag: "InvalidRequestError", message: "invalid" }
},
}))
const streamError = await collectError(transport.stream("session", 2))
expect(streamError).toBeInstanceOf(Engine.SeqUnavailable)
await expect(
transport.submit({ id: "msg_client", sessionID: "session", request: { text: "invalid" } }),
).rejects.toEqual(new Engine.SubmitRejected("invalid"))
})
})
async function collectError(iterable: AsyncIterable<unknown>) {
try {
for await (const item of iterable) void item
} catch (error) {
return error
}
throw new Error("stream did not fail")
}
-185
View File
@@ -1,185 +0,0 @@
import type { SessionInfo, SessionMessageInfo } from "../../src/promise"
import { Engine } from "../../src/solid/engine/engine"
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "../../src/solid/engine/fold"
import { SessionFold } from "../../src/solid/engine/fold"
export class FakeSessionServer implements Engine.SessionTransport {
readonly events: Array<DurableSessionEvent> = []
readonly admitted: Array<string> = []
readonly faults = {
loseRequests: 0,
loseResponses: 0,
reject: 0,
latency: 0,
}
private folded: SessionFoldState
private readonly tails = new Set<AsyncQueue<Engine.SessionStreamItem>>()
private eventCounter = 0
constructor(
readonly sessionID: string,
readonly time = 1_717_171_717_000,
) {
this.folded = SessionFold.fromSnapshot(emptySnapshot(sessionID))
}
async snapshot(sessionID: string) {
await this.pause()
this.assertSession(sessionID)
return this.snapshotValue()
}
async *stream(sessionID: string, after: number): AsyncIterable<Engine.SessionStreamItem> {
await this.pause()
this.assertSession(sessionID)
if (after > this.folded.seq) throw new Engine.SeqUnavailable()
const replay = this.events.filter((event) => event.durable.seq > after)
// Honest replay contract: a cursor is only admitted when retained events fully cover (after, seq].
if (replay.length < this.folded.seq - after) throw new Engine.SeqUnavailable()
const queue = new AsyncQueue<Engine.SessionStreamItem>()
this.tails.add(queue)
try {
for (const event of replay) yield event
yield { type: "log.synced", aggregateID: sessionID, seq: this.folded.seq }
while (true) yield await queue.take()
} finally {
this.tails.delete(queue)
}
}
async submit(input: Engine.SubmitInput) {
await this.pause()
this.assertSession(input.sessionID)
const existing = this.events.find(
(event) => event.type === "session.inbox.enqueued" && event.data.inboxID === input.id,
)
if (existing) return
if (this.faults.loseRequests > 0) {
this.faults.loseRequests--
throw new Error("request lost")
}
if (this.faults.reject > 0) {
this.faults.reject--
throw new Engine.SubmitRejected("rejected")
}
this.admitted.push(input.id)
this.publish({
id: `evt_${String(++this.eventCounter).padStart(8, "0")}`,
created: this.time,
type: "session.inbox.enqueued",
durable: { aggregateID: this.sessionID, seq: this.folded.seq + 1, version: 1 },
data: {
sessionID: this.sessionID,
inboxID: input.id,
item: {
type: "user",
delivery: input.request.delivery ?? "steer",
payload: {
text: input.request.text,
agents: input.request.agents?.map((agent) => ({ ...agent })),
metadata: input.request.metadata,
},
},
},
})
if (this.faults.loseResponses > 0) {
this.faults.loseResponses--
throw new Error("response lost")
}
}
cutConnections() {
this.tails.forEach((tail) => tail.fail(new Error("connection cut")))
}
/** Drop retained event history, simulating `events.persist` off or pruned retention. */
prune() {
this.events.length = 0
}
truth() {
return Engine.render({ folded: this.folded, outbox: [], overlay: new Map() })
}
snapshotValue(): SessionSnapshot {
return {
session: this.folded.session,
children: this.folded.children,
inbox: this.folded.inbox,
messages: this.folded.messages,
seq: this.folded.seq,
active: this.folded.active,
}
}
private publish(event: DurableSessionEvent) {
this.events.push(event)
this.folded = SessionFold.apply(this.folded, event)
this.tails.forEach((tail) => tail.offer(event))
}
private assertSession(sessionID: string) {
if (sessionID !== this.sessionID) throw new Error(`unknown session: ${sessionID}`)
}
private async pause() {
for (let step = 0; step < this.faults.latency; step++) await Promise.resolve()
}
}
export async function until(check: () => boolean, message = "condition did not become true") {
for (let attempt = 0; attempt < 500; attempt++) {
if (check()) return
await Bun.sleep(1)
}
throw new Error(message)
}
export function userMessages(messages: ReadonlyArray<SessionMessageInfo>) {
return messages.filter(
(message): message is Extract<SessionMessageInfo, { readonly type: "user" }> => message.type === "user",
)
}
function emptySnapshot(sessionID: string): SessionSnapshot {
const session: SessionInfo = {
id: sessionID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1_717_171_717_000, updated: 1_717_171_717_000 },
location: { directory: "/workspace" },
}
return { session, children: [], inbox: [], messages: [], seq: 0 }
}
class AsyncQueue<Value> {
private readonly values: Array<Value> = []
private readonly waiting: Array<{
readonly resolve: (value: Value) => void
readonly reject: (error: Error) => void
}> = []
private error?: Error
offer(value: Value) {
const waiter = this.waiting.shift()
if (waiter) {
waiter.resolve(value)
return
}
this.values.push(value)
}
fail(error: Error) {
this.error = error
this.waiting.splice(0).forEach((waiter) => waiter.reject(error))
}
take() {
const value = this.values.shift()
if (value) return Promise.resolve(value)
if (this.error) return Promise.reject(this.error)
return new Promise<Value>((resolve, reject) => this.waiting.push({ resolve, reject }))
}
}
@@ -1,167 +0,0 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { Engine } from "../src/solid/engine/engine"
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
describe("session sync engine laws", () => {
test("1. idempotency: lost responses converge to one admitted message", async () => {
const server = new FakeSessionServer("session-idempotency")
server.faults.loseResponses = 1
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: async () => {},
})
engine.submit({ id: "msg_1", text: "hello" })
await until(() => engine.view().seq === 1)
server.cutConnections()
await engine.settled()
expect(server.admitted).toEqual(["msg_1"])
expect(userMessages(engine.view().messages)).toHaveLength(1)
engine.stop()
})
test("2. echo determinism: folding the echo does not change rendered messages", async () => {
const server = new FakeSessionServer("session-echo")
const engine = await Engine.createSessionEngine(server.sessionID, server, { now: () => server.time })
await until(() => server.admitted.length === 0 && engine.view().seq === 0)
engine.submit({ id: "msg_1", text: "instant" })
const before = engine.view().messages
await engine.settled()
expect(engine.view().messages).toEqual(before)
engine.stop()
})
test("3. sync opacity: the fold cannot see intents or the engine", () => {
const source = readFileSync(new URL("../src/solid/engine/fold.ts", import.meta.url), "utf8")
const code = source
.split("\n")
.filter((line) => !line.trim().startsWith("//") && !line.trim().startsWith("*"))
.join("\n")
expect(code).not.toContain("outbox")
expect(code).not.toContain("./engine")
expect(code).not.toContain("intent")
})
test("4. ordering: a burst admits in submission order", async () => {
const server = new FakeSessionServer("session-ordering")
const engine = await Engine.createSessionEngine(server.sessionID, server)
for (const value of [1, 2, 3, 4, 5]) engine.submit({ id: `msg_${value}`, text: `m${value}` })
await engine.settled()
expect(server.admitted).toEqual(["msg_1", "msg_2", "msg_3", "msg_4", "msg_5"])
engine.stop()
})
test("5. convergence: drained clients equal the server fold", async () => {
const server = new FakeSessionServer("session-convergence")
const a = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_a" })
const b = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_b" })
a.submit({ text: "from a" })
b.submit({ text: "from b" })
await Promise.all([a.settled(), b.settled()])
await until(() => a.view().seq === server.events.length && b.view().seq === server.events.length)
expect(a.view()).toEqual(server.truth())
expect(b.view()).toEqual(server.truth())
a.stop()
b.stop()
})
test("6. failure atomicity: typed rejection removes and surfaces the intent", async () => {
const server = new FakeSessionServer("session-failure")
server.faults.reject = 1
const engine = await Engine.createSessionEngine(server.sessionID, server)
const failures: Array<Engine.IntentFailure> = []
engine.subscribeFailures((failure) => failures.push(failure))
const before = engine.view()
const intent = engine.submit({ id: "msg_1", text: "doomed" })
expect(userMessages(engine.view().messages)).toHaveLength(1)
await until(() => failures.length === 1)
expect(engine.view()).toEqual(before)
expect(failures).toEqual([{ intent, reason: "rejected" }])
engine.stop()
})
test("7. lossy history: reconnect without retained events recovers via snapshot", async () => {
const server = new FakeSessionServer("session-lossy")
let release: (() => void) | undefined
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: () => new Promise<void>((resolve) => (release = resolve)),
})
engine.submit({ id: "msg_1", text: "first" })
await engine.settled()
server.cutConnections()
await until(() => release !== undefined)
// While disconnected the session advances, then history is dropped: the
// reconnect cursor cannot be replayed and must recover via snapshot.
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
server.prune()
release!()
await until(() => engine.view().seq === 2)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("8. attach gaps: a synced marker past the fold forces snapshot recovery", async () => {
const server = new FakeSessionServer("session-marker-gap")
await server.submit({ id: "msg_1", sessionID: server.sessionID, request: { text: "hello" } })
const stale = { ...server.snapshotValue(), messages: [], inbox: [], seq: 0 }
let attempts = 0
const engine = await Engine.createSessionEngine(
server.sessionID,
{
snapshot: (sessionID) => (attempts === 0 ? Promise.resolve(stale) : server.snapshot(sessionID)),
async *stream(sessionID, after, signal) {
attempts++
if (attempts === 1) {
// Dishonest attach: the marker admits the cursor but skips the replay range.
yield { type: "log.synced" as const, aggregateID: sessionID, seq: server.snapshotValue().seq }
return
}
yield* server.stream(sessionID, after, signal)
},
submit: (input) => server.submit(input),
},
{ reconnect: async () => {} },
)
await engine.ready()
expect(attempts).toBe(2)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("snapshot refresh cannot move the fold behind the live log", async () => {
const server = new FakeSessionServer("session-refresh-race")
const stale = server.snapshotValue()
let refresh = false
const transport: Engine.SessionTransport = {
snapshot: (sessionID) => (refresh ? Promise.resolve(stale) : server.snapshot(sessionID)),
stream: (sessionID, after) => server.stream(sessionID, after),
submit: (input) => server.submit(input),
}
const engine = await Engine.createSessionEngine(server.sessionID, transport)
await engine.ready()
engine.submit({ id: "msg_1", text: "newer than snapshot" })
await until(() => engine.view().seq === 1)
refresh = true
await engine.refresh()
expect(engine.view().seq).toBe(1)
expect(engine.view().pending.map((item) => item.id)).toEqual(["msg_1"])
engine.stop()
})
})
@@ -1,113 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Engine } from "../src/solid/engine/engine"
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
type Client = {
readonly name: string
readonly engine: Engine.SessionEngine
readonly submitted: Array<string>
readonly rejected: Set<string>
readonly views: Array<Engine.SessionView>
}
describe("session sync engine simulation", () => {
for (const seed of [1, 2, 3, 42, 1337, 90210]) {
test(`seed ${seed}: two clients converge through chaotic transport faults`, async () => {
const random = mulberry32(seed)
const server = new FakeSessionServer(`session-sim-${seed}`)
const clients = await Promise.all([makeClient("a", server), makeClient("b", server)])
for (let step = 0; step < 80; step++) {
const roll = random()
if (roll < 0.45) {
const client = pick(clients, random)
const intent = client.engine.submit({ text: `step-${step}` })
client.submitted.push(intent.id)
} else if (roll < 0.55) {
server.cutConnections()
} else if (roll < 0.65) {
server.faults.loseResponses++
} else if (roll < 0.73) {
server.faults.loseRequests += 1 + Math.floor(random() * 2)
} else if (roll < 0.8) {
server.faults.reject++
} else {
server.faults.latency = Math.floor(random() * 6)
}
await advance(2 + Math.floor(random() * 8))
}
server.faults.latency = 0
server.faults.loseRequests = 0
server.faults.loseResponses = 0
server.faults.reject = 0
for (let attempt = 0; attempt < 100; attempt++) {
server.cutConnections()
await advance(4)
const accounted = clients.every(
(client) =>
client.submitted.filter((id) => server.admitted.includes(id) || client.rejected.has(id)).length ===
client.submitted.length,
)
if (accounted) break
}
await until(
() => clients.every((client) => client.engine.view().seq === server.events.length),
`seed ${seed} did not converge`,
)
expect(new Set(server.admitted).size).toBe(server.admitted.length)
for (const client of clients) {
const expected = client.submitted.filter((id) => !client.rejected.has(id))
const observed = server.admitted.filter((id) => client.submitted.includes(id))
expect(observed).toEqual(expected)
expect(client.engine.view()).toEqual(server.truth())
assertNoFlicker(client.views, server.admitted, `${seed}/${client.name}`)
client.engine.stop()
}
})
}
})
async function makeClient(name: string, server: FakeSessionServer): Promise<Client> {
let counter = 0
const engine = await Engine.createSessionEngine(server.sessionID, server, {
makeID: () => `msg_${name}${String(++counter).padStart(4, "0")}`,
now: () => server.time,
reconnect: async () => {},
})
const client: Client = { name, engine, submitted: [], rejected: new Set(), views: [engine.view()] }
engine.subscribe((view) => client.views.push(view))
engine.subscribeFailures((failure) => client.rejected.add(failure.intent.id))
return client
}
function assertNoFlicker(views: ReadonlyArray<Engine.SessionView>, admitted: ReadonlyArray<string>, label: string) {
for (const id of admitted) {
const first = views.findIndex((view) => userMessages(view.messages).some((message) => message.id === id))
expect(first, `${label}: ${id} never rendered`).toBeGreaterThanOrEqual(0)
for (const view of views.slice(first)) {
const rows = userMessages(view.messages).filter((message) => message.id === id)
expect(rows, `${label}: ${id} disappeared or duplicated`).toHaveLength(1)
}
}
}
async function advance(steps: number) {
for (let step = 0; step < steps; step++) await Promise.resolve()
await Bun.sleep(0)
}
function pick<Value>(values: ReadonlyArray<Value>, random: () => number) {
return values[Math.floor(random() * values.length)]!
}
function mulberry32(seed: number) {
return () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
const first = Math.imul(seed ^ (seed >>> 15), 1 | seed)
const second = (first + Math.imul(first ^ (first >>> 7), 61 | first)) ^ first
return ((second ^ (second >>> 14)) >>> 0) / 4294967296
}
}
+1 -27
View File
@@ -44,21 +44,6 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
.pipe(Effect.orDie)
})
export const retainedCount = Effect.fn("Bus.retainedCount")(function* (
db: Database.Interface["db"],
aggregateID: string,
after: number,
through: number,
) {
const row = yield* db
.select({ count: sql<number>`count(*)` })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through)))
.get()
.pipe(Effect.orDie)
return row?.count ?? 0
})
export type SerializedEvent = {
readonly id: Event.ID
readonly type: string
@@ -165,7 +150,6 @@ export interface Interface {
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
readonly includeLive?: (event: Event.Payload) => boolean
}) => Stream.Stream<LogItem>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
@@ -784,7 +768,6 @@ export function configured(options?: Options) {
readonly aggregateID: string
readonly after?: number
readonly follow?: boolean
readonly includeLive?: (event: Event.Payload) => boolean
}): Stream.Stream<LogItem> =>
Stream.unwrap(
Effect.gen(function* () {
@@ -808,8 +791,7 @@ export function configured(options?: Options) {
)
// Subscribing before the historical read means events committed during
// replay either appear in the read or arrive through a post-marker wake.
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
const target = yield* latestSequence(db, input.aggregateID)
const marker: EventLog.Synced = {
type: "log.synced",
@@ -820,14 +802,6 @@ export function configured(options?: Options) {
Stream.map((event): LogItem => event),
Stream.concat(Stream.make(marker)),
)
if (subscription && input.includeLive) {
const follow: Stream.Stream<LogItem> = Stream.fromSubscription(subscription).pipe(
Stream.filter(input.includeLive),
Stream.filter((event) => !event.durable || event.durable.seq > target),
Stream.map((event): LogItem => event),
)
return Stream.concat(replay, follow)
}
if (!wakes) return replay
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
@@ -1,43 +1,10 @@
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import type { DatabaseMigration } from "../migration.js"
const previousV2Marker = "20260730195856_optional_session_title"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
up(tx) {
return Effect.gen(function* () {
// This marker identifies the completed pre-split V2 lineage. Its V2 tables
// are canonical, so rename them in place instead of replaying the V1 squash.
if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) {
const v1Only = yield* tx.get(sql`
SELECT 1
FROM message
WHERE NOT EXISTS (
SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id
)
LIMIT 1
`)
if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history"))
yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`)
yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`)
yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`)
return
}
yield* tx.run(`
CREATE TABLE IF NOT EXISTS \`kv\` (
\`key\` text PRIMARY KEY,
+1 -1
View File
@@ -33,7 +33,7 @@ const layer = Layer.effect(
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Prefer ${global.tmp} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
` Use ${global.tmp} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
].join("\n"),
),
-6
View File
@@ -15,10 +15,8 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { GroqPlugin } from "./provider/groq.js"
import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
@@ -29,7 +27,6 @@ import { SapAICorePlugin } from "./provider/sap-ai-core.js"
import { TogetherAIPlugin } from "./provider/togetherai.js"
import { VercelPlugin } from "./provider/vercel.js"
import { VenicePlugin } from "./provider/venice.js"
import { VLLMPlugin } from "./provider/vllm.js"
import { XAIPlugin } from "./provider/xai.js"
import { ZenmuxPlugin } from "./provider/zenmux.js"
import type { PluginInternal } from "./internal.js"
@@ -51,10 +48,8 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
SnowflakeCortexPlugin,
OpenAICompatiblePlugin,
@@ -65,7 +60,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
TogetherAIPlugin,
VercelPlugin,
VenicePlugin,
VLLMPlugin,
XAIPlugin,
ZenmuxPlugin,
DynamicProviderPlugin,
@@ -1,174 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = "lmstudio"
const RemoteModel = Schema.Struct({
type: Schema.Literals(["llm", "embedding"]),
key: Schema.String,
display_name: Schema.String,
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
loaded_instances: Schema.Array(
Schema.Struct({
config: Schema.Struct({ context_length: Schema.Int }),
}),
),
max_context_length: Schema.Int,
capabilities: Schema.Struct({
vision: Schema.Boolean,
trained_for_tool_use: Schema.Boolean,
}).pipe(Schema.optional),
})
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
const discoveryLock = Semaphore.makeUnsafe(1)
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
return define({
id: "opencode.provider.lmstudio",
effect: Effect.fn(function* (ctx) {
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const config = yield* Config.Service
const source = { current: configured(yield* config.entries(), origin) }
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
yield* ctx.integration.transform((integrations) => {
if (loaded.models.length === 0) return
integrations.remove(providerID)
})
yield* ctx.catalog.transform((catalog) => {
if (loaded.models.length === 0) return
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
catalog.model.remove(providerID, model.id)
}
catalog.provider.update(providerID, (provider) => {
provider.name = "LM Studio"
provider.activation = "enabled"
provider.package = "@opencode-ai/ai/providers/openai-compatible"
provider.settings = {
baseURL: source.current.baseURL,
provider: providerID,
apiKey: source.current.apiKey ?? "",
}
provider.integrationID = undefined
})
for (const item of loaded.models) {
catalog.model.update(providerID, item.key, (model) => {
model.modelID = Model.ID.make(item.key)
model.name = item.display_name || item.key
model.family = item.architecture ? Model.Family.make(item.architecture) : undefined
model.capabilities = {
tools: item.capabilities?.trained_for_tool_use ?? false,
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
item.loaded_instances.length === 0
? item.max_context_length
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
output: 0,
}
})
}
})
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
const current = source.current
if (!current.endpoint) return undefined
return yield* discoveryLock.withPermit(
Effect.gen(function* () {
const cached = discovery.get(current.endpoint)
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
return { source: current, models: cached.models }
discovery.set(current.endpoint, {
checked: Date.now(),
apiKey: current.apiKey,
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
})
const request = current.apiKey
? HttpClientRequest.get(current.endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(current.apiKey),
)
: HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
const response = yield* http
.execute(request)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
const models = response.models
.filter((model) => model.type === "llm" && model.key.length > 0)
.toSorted((a, b) => a.key.localeCompare(b.key))
discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
return { source: current, models }
}),
)
})
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
const result = yield* discover()
if (!result?.models || result.source !== source.current) return
const hash = JSON.stringify(result.models)
if (hash === loaded.hash) return
loaded.models = result.models
loaded.hash = hash
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
})
// Keep the last successful inventory through transient outages instead of flickering model availability.
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
const reload = Effect.fn("LMStudioPlugin.reload")(function* () {
const next = configured(yield* config.entries(), origin)
if (
next.baseURL === source.current.baseURL &&
next.apiKey === source.current.apiKey &&
next.endpoint === source.current.endpoint
)
return
source.current = next
loaded.models = []
loaded.hash = "[]"
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
yield* refresh().pipe(Effect.ignore)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const LMStudioPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
const url = new URL(baseURL)
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
url.pathname = `${prefix}/api/v1/models`
url.search = ""
url.hash = ""
return { baseURL, apiKey, endpoint: url.toString() }
}
-233
View File
@@ -1,233 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = "ollama"
const Details = Schema.Struct({
parent_model: Schema.String.pipe(Schema.optional),
format: Schema.String,
family: Schema.String,
families: Schema.Array(Schema.String).pipe(Schema.optional),
parameter_size: Schema.String,
quantization_level: Schema.String,
})
const RemoteModel = Schema.Struct({
name: Schema.String,
model: Schema.String,
remote_model: Schema.String.pipe(Schema.optional),
remote_host: Schema.String.pipe(Schema.optional),
modified_at: Schema.String,
size: Schema.Int,
digest: Schema.String,
details: Details,
})
const TagsResponse = Schema.Struct({ models: Schema.Array(RemoteModel) })
const ShowRequest = Schema.Struct({ model: Schema.String })
const ShowResponse = Schema.Struct({
parameters: Schema.String.pipe(Schema.optional),
license: Schema.String.pipe(Schema.optional),
modified_at: Schema.String.pipe(Schema.optional),
details: Details.pipe(Schema.optional),
template: Schema.String.pipe(Schema.optional),
capabilities: Schema.Array(Schema.String).pipe(Schema.optional),
model_info: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
})
type DiscoveredModel = typeof RemoteModel.Type & { show: typeof ShowResponse.Type }
type Discovery = {
checked: number
apiKey?: string
models?: DiscoveredModel[]
shows: Map<string, { digest: string; info: typeof ShowResponse.Type }>
}
const discovery = new Map<string, Discovery>()
const discoveryLock = Semaphore.makeUnsafe(1)
export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input = "30 seconds") {
return define({
id: "opencode.provider.ollama",
effect: Effect.fn(function* (ctx) {
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const config = yield* Config.Service
const source = { current: configured(yield* config.entries(), origin) }
const loaded = { models: [] as DiscoveredModel[], hash: "[]" }
yield* ctx.integration.transform((integrations) => {
if (loaded.models.length === 0) return
integrations.remove(providerID)
})
yield* ctx.catalog.transform((catalog) => {
if (loaded.models.length === 0) return
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
catalog.model.remove(providerID, model.id)
}
catalog.provider.update(providerID, (provider) => {
provider.name = "Ollama"
provider.activation = "enabled"
provider.package = "@opencode-ai/ai/providers/openai-compatible"
provider.settings = {
baseURL: source.current.baseURL,
provider: providerID,
apiKey: source.current.apiKey ?? "",
}
provider.integrationID = undefined
})
for (const item of loaded.models) {
catalog.model.update(providerID, item.model, (model) => {
model.modelID = Model.ID.make(item.model)
model.name = item.name || item.model
model.family = item.show.details?.family
? Model.Family.make(item.show.details.family)
: item.details.family
? Model.Family.make(item.details.family)
: undefined
model.capabilities = {
tools: item.show.capabilities?.includes("tools") ?? false,
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
)[0] ?? 0,
output: 0,
}
})
}
})
const discover = Effect.fn("OllamaPlugin.discover")(function* () {
const current = source.current
if (!current.tagsEndpoint || !current.showEndpoint) return undefined
return yield* discoveryLock.withPermit(
Effect.gen(function* () {
const cached = discovery.get(current.tagsEndpoint)
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
return { source: current, models: cached.models }
const previous: Discovery =
cached && cached.apiKey === current.apiKey
? cached
: { checked: 0, apiKey: current.apiKey, shows: new Map() }
discovery.set(current.tagsEndpoint, { ...previous, checked: Date.now(), apiKey: current.apiKey })
const tagsRequest = current.apiKey
? HttpClientRequest.get(current.tagsEndpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(current.apiKey),
)
: HttpClientRequest.get(current.tagsEndpoint).pipe(HttpClientRequest.acceptJson)
const response = yield* http
.execute(tagsRequest)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(TagsResponse)), Effect.timeout("1 second"))
const summaries = response.models
.filter((model) => model.model.length > 0)
.toSorted((a, b) => a.model.localeCompare(b.model))
const shows = new Map<string, { digest: string; info: typeof ShowResponse.Type }>()
const models = yield* Effect.forEach(
summaries,
(model) =>
Effect.gen(function* () {
const saved = previous.shows.get(model.model)
const info =
saved?.digest === model.digest
? saved.info
: yield* HttpClientRequest.post(current.showEndpoint).pipe(
HttpClientRequest.acceptJson,
current.apiKey ? HttpClientRequest.bearerToken(current.apiKey) : (request) => request,
HttpClientRequest.schemaBodyJson(ShowRequest)({ model: model.model }),
Effect.flatMap(http.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShowResponse)),
Effect.timeout("1 second"),
)
shows.set(model.model, { digest: model.digest, info })
return { ...model, show: info }
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
{ concurrency: 4 },
)
const filtered = models.filter(
(model): model is DiscoveredModel =>
model !== undefined && (model.show.capabilities?.includes("completion") ?? false),
)
discovery.set(current.tagsEndpoint, {
checked: Date.now(),
apiKey: current.apiKey,
models: filtered,
shows,
})
return { source: current, models: filtered }
}),
)
})
const refresh = Effect.fn("OllamaPlugin.refresh")(function* () {
const result = yield* discover()
if (!result?.models || result.source !== source.current) return
const hash = JSON.stringify(result.models)
if (hash === loaded.hash) return
loaded.models = result.models
loaded.hash = hash
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
})
// Keep the last successful inventory through transient outages instead of flickering model availability.
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
const reload = Effect.fn("OllamaPlugin.reload")(function* () {
const next = configured(yield* config.entries(), origin)
if (
next.baseURL === source.current.baseURL &&
next.apiKey === source.current.apiKey &&
next.tagsEndpoint === source.current.tagsEndpoint
)
return
source.current = next
loaded.models = []
loaded.hash = "[]"
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
yield* refresh().pipe(Effect.ignore)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const OllamaPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
const url = new URL(baseURL)
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
url.pathname = `${prefix}/api/tags`
url.search = ""
url.hash = ""
const tagsEndpoint = url.toString()
url.pathname = `${prefix}/api/show`
return { baseURL, apiKey, tagsEndpoint, showEndpoint: url.toString() }
}
-162
View File
@@ -1,162 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = "vllm"
const RemoteModel = Schema.Struct({
id: Schema.String,
owned_by: Schema.String,
max_model_len: Schema.NullOr(Schema.Int),
})
const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
const discoveryLock = Semaphore.makeUnsafe(1)
export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input = "30 seconds") {
return define({
id: "opencode.provider.vllm",
effect: Effect.fn(function* (ctx) {
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const config = yield* Config.Service
const source = { current: configured(yield* config.entries(), origin) }
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
yield* ctx.integration.transform((integrations) => {
if (loaded.models.length === 0) return
integrations.remove(providerID)
})
yield* ctx.catalog.transform((catalog) => {
if (loaded.models.length === 0) return
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
catalog.model.remove(providerID, model.id)
}
catalog.provider.update(providerID, (provider) => {
provider.name = "vLLM"
provider.package = "@opencode-ai/ai/providers/openai-compatible"
provider.settings = {
baseURL: source.current.baseURL,
provider: providerID,
apiKey: source.current.apiKey ?? "",
}
provider.integrationID = undefined
provider.activation = "enabled"
})
for (const item of loaded.models) {
catalog.model.update(providerID, item.id, (model) => {
model.modelID = Model.ID.make(item.id)
model.name = item.id
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
model.limit = { context: item.max_model_len ?? 0, output: 0 }
})
}
})
const discover = Effect.fn("VLLMPlugin.discover")(function* () {
const current = source.current
if (!current.healthEndpoint || !current.modelsEndpoint) return undefined
return yield* discoveryLock.withPermit(
Effect.gen(function* () {
const endpoint = `${current.healthEndpoint}\n${current.modelsEndpoint}`
const cached = discovery.get(endpoint)
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
return { source: current, models: cached.models }
discovery.set(endpoint, {
checked: Date.now(),
apiKey: current.apiKey,
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
})
const request = (endpoint: string) =>
current.apiKey
? HttpClientRequest.get(endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(current.apiKey),
)
: HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson)
yield* http.execute(request(current.healthEndpoint)).pipe(Effect.timeout("1 second"))
const response = yield* http
.execute(request(current.modelsEndpoint))
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
const models = response.data
.filter((model) => model.owned_by === providerID && model.id.length > 0)
.toSorted((a, b) => a.id.localeCompare(b.id))
discovery.set(endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
return { source: current, models }
}),
)
})
const refresh = Effect.fn("VLLMPlugin.refresh")(function* () {
const result = yield* discover()
if (!result?.models || result.source !== source.current) return
const hash = JSON.stringify(result.models)
if (hash === loaded.hash) return
loaded.models = result.models
loaded.hash = hash
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
})
// Keep the last successful inventory through transient outages instead of flickering model availability.
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
const reload = Effect.fn("VLLMPlugin.reload")(function* () {
const next = configured(yield* config.entries(), origin)
if (
next.baseURL === source.current.baseURL &&
next.apiKey === source.current.apiKey &&
next.healthEndpoint === source.current.healthEndpoint &&
next.modelsEndpoint === source.current.modelsEndpoint
)
return
source.current = next
loaded.models = []
loaded.hash = "[]"
yield* ctx.integration.reload()
yield* ctx.catalog.reload()
yield* refresh().pipe(Effect.ignore)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const VLLMPlugin = make()
function configured(entries: readonly Entry[], origin: string) {
const settings = entries
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => {
const settings = entry.info.providers?.[providerID]?.settings
return settings ? [settings] : []
})
.reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
const baseURL = (
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
).replace(/\/+$/, "")
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
const models = new URL(baseURL)
if (models.protocol !== "http:" && models.protocol !== "https:") return { baseURL, apiKey }
models.pathname = `${models.pathname.replace(/\/+$/, "")}/models`
models.search = ""
models.hash = ""
const health = new URL(baseURL)
const path = health.pathname.replace(/\/+$/, "")
const prefix = path.endsWith("/v1") ? path.slice(0, -3) : path
health.pathname = `${prefix}/health`
health.search = ""
health.hash = ""
return { baseURL, apiKey, healthEndpoint: health.toString(), modelsEndpoint: models.toString() }
}
+3 -38
View File
@@ -29,41 +29,10 @@ V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
must still use V2 unless the user specifically requests a V1 result.
## [CLI](https://opencode.ai/v2/docs/cli)
## [Configuration](https://opencode.ai/v2/docs/config)
For questions about the terminal interface, command-line invocation, `run`,
`mini`, terminal providers, or other CLI behavior, fetch the
[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from
that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
and terminal integration. Do not put these settings in `opencode.json(c)`.
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
Configure keybindings under `keybinds` in `cli.json`. The leader key is the
`keybinds.leader` entry; leader timing is configured separately under
`leader.timeout`. Bindings can use a string, an array of strings, or an object
when event behavior such as `preventDefault` is required. Disable a binding
with `"none"` or `false`.
Never guess a command ID, default binding, or accepted key syntax. Fetch the
full [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists
the current IDs and defaults, before answering or editing a binding.
## [OpenCode configuration](https://opencode.ai/v2/docs/config)
OpenCode's server and project configuration uses JSON or JSONC. Include the
published schema so the user's editor can validate fields and provide
autocomplete:
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
```jsonc
{
@@ -86,10 +55,6 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
`references`, `formatter`, and `lsp`.
This configuration is distinct from `cli.json`. Use the
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
preferences, especially themes and keybindings.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
linked topic guide as the source of truth, and preserve unrelated settings when
editing an existing file. Keep the published `$schema` URL in configuration
+15 -112
View File
@@ -139,11 +139,6 @@ export class InboxConflictError extends Schema.TaggedErrorClass<InboxConflictErr
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export class SeqUnavailableError extends Schema.TaggedErrorClass<SeqUnavailableError>()("Session.SeqUnavailableError", {
sessionID: SessionSchema.ID,
after: Event.Seq,
head: Schema.optional(Event.Seq),
}) {}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
@@ -193,33 +188,13 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
readonly snapshot: (input: {
sessionID: SessionSchema.ID
recent?: number
}) => Effect.Effect<
{
readonly session: SessionSchema.Info
readonly children: SessionSchema.Info[]
readonly inbox: SessionInbox.Info[]
readonly messages: SessionMessage.Info[]
readonly seq: Event.Seq
},
NotFoundError | MessageDecodeError
>
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly openLog: (input: {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
ephemeral?: boolean
}) => Effect.Effect<Stream.Stream<SessionEvent.Event | EventLog.Synced>, NotFoundError | SeqUnavailableError>
/**
* Ordered session log read. Replays durable session events after the
* exclusive `after` cursor, emits a `Synced` marker at the captured replay
* watermark, then continues live when `follow` is set. Ephemeral events are
* included only in the live phase when explicitly requested.
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
* replay watermark, then continues live when `follow` is set.
* The marker's seq may exceed the last emitted event because other durable
* bus share the aggregate's sequence space.
*/
@@ -227,8 +202,7 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
ephemeral?: boolean
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
@@ -345,7 +319,6 @@ const layer = Layer.effect(
})
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const isSessionEvent = Schema.is(SessionEvent.All)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
@@ -571,90 +544,20 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
}),
snapshot: Effect.fn("Session.snapshot")(function* (input) {
return yield* db
.transaction(() =>
Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
const children = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.parent_id, input.sessionID))
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
.all()
.pipe(Effect.orDie)
const inbox = yield* SessionInbox.list(db, input.sessionID)
const messages = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, input.sessionID))
.orderBy(desc(SessionMessageTable.seq))
.limit(input.recent ?? 200)
.all()
.pipe(Effect.orDie)
const seq = yield* Bus.latestSequence(db, input.sessionID)
if (seq < 0) return yield* Effect.die(new Error(`Session ${input.sessionID} has no event sequence`))
return {
session: fromRow(row),
children: children.map(fromRow),
inbox,
messages: yield* Effect.forEach(messages.toReversed(), decode),
seq: Event.Seq.make(seq),
}
}),
)
.pipe(Effect.catchTag("SqlError", Effect.die))
}),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
openLog: Effect.fn("Session.openLog")(function* (input) {
yield* result.get(input.sessionID)
if (input.after !== undefined) {
const head = yield* Bus.latestSequence(db, input.sessionID)
if (input.after > head)
return yield* new SeqUnavailableError({
sessionID: input.sessionID,
after: Event.Seq.make(input.after),
head: head >= 0 ? Event.Seq.make(head) : undefined,
})
// A cursor claims the caller already holds everything through `after`, so
// replay of (after, head] must be provably complete. Without retained rows
// covering the range (events.persist off, or pruned history) replaying
// nothing would silently desync the caller; fail so it re-snapshots instead.
if (input.after < head) {
const retained = yield* Bus.retainedCount(db, input.sessionID, input.after, head)
if (retained < head - input.after)
return yield* new SeqUnavailableError({
sessionID: input.sessionID,
after: Event.Seq.make(input.after),
head: Event.Seq.make(head),
})
}
}
return bus
.log({
aggregateID: input.sessionID,
after: input.after,
follow: input.follow,
includeLive: input.ephemeral
? (event) => isSessionEvent(event) && event.data.sessionID === input.sessionID
: undefined,
})
.pipe(
Stream.filter(
(item): item is SessionEvent.Event | EventLog.Synced =>
Bus.isSynced(item) || (input.ephemeral ? isSessionEvent(item) : isDurableSessionEvent(item)),
),
)
}),
log: (input) => Stream.unwrap(result.openLog(input)),
log: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(bus.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
).pipe(
Stream.filter(
(item): item is SessionEvent.DurableEvent | EventLog.Synced =>
Bus.isSynced(item) || isDurableSessionEvent(item),
),
),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
+1 -1
View File
@@ -324,7 +324,7 @@ export const layer = (options?: ShellSelect.Options) =>
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => finish("exited")),
Effect.catch(() => Effect.void),
),
)
@@ -13,10 +13,6 @@ import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain"
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -132,142 +128,6 @@ describe("DatabaseMigration", () => {
)
})
test("preserves previous V2 state through the current migration lineage", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(sql`
CREATE TABLE project_directory (
project_id text NOT NULL,
directory text NOT NULL,
type text,
strategy text,
time_created integer NOT NULL,
PRIMARY KEY (project_id, directory)
)
`)
yield* db.run(sql`
CREATE TABLE workspace (
id text PRIMARY KEY,
type text NOT NULL,
name text NOT NULL,
project_id text NOT NULL,
time_used integer NOT NULL
)
`)
yield* db.run(sql`
CREATE TABLE session (
id text PRIMARY KEY,
project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE,
workspace_id text,
parent_id text,
time_suspended integer
)
`)
yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`)
yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`)
yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`)
yield* db.run(
sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`,
)
yield* db.run(sql`
CREATE TABLE session_message (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`
CREATE TABLE session_pending (
id text PRIMARY KEY,
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
)
`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(sql`
CREATE TABLE event (
id text PRIMARY KEY,
aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE,
seq integer NOT NULL,
created integer NOT NULL,
type text NOT NULL,
data text NOT NULL
)
`)
yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`)
yield* db.run(sql`INSERT INTO project VALUES ('project')`)
yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`)
yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`)
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`)
yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`)
yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`)
yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`)
yield* DatabaseMigration.applyOnly(db, [
previousV2Migration,
workspaceMigration,
executionClaimsMigration,
sessionInboxMigration,
worktreeMigration,
])
expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({
id: "session",
resume_attempts: 0,
})
expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({
id: "message",
data: '{"text":"preserved"}',
})
expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" })
expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 })
expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`),
).toBeUndefined()
expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" })
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual(
expect.objectContaining({ table: "session_v2" }),
)
}),
)
})
test("rejects previous V2 databases with V1-only session history", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`)
yield* db.run(sql`INSERT INTO session VALUES ('session')`)
yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure")
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined()
}),
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
@@ -51,7 +51,7 @@ describe("InstructionBuiltIns", () => {
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
` Use ${temporary} for temporary work outside the workspace; it already exists and is pre-approved for external directory access.`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
@@ -1,341 +0,0 @@
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
const decode = Schema.decodeUnknownSync(Info)
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* make(origin, interval).effect(host)
})
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
describe("LMStudioPlugin", () => {
it.effect("is registered as a built-in provider plugin", () =>
Effect.sync(() => {
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
}),
)
it.live("discovers local language models with their capabilities and effective context", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: () =>
Response.json({
models: [
{
type: "llm",
key: "google/gemma-4-26b-a4b",
display_name: "Gemma 4 26B A4B",
architecture: "gemma4",
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
max_context_length: 262_144,
capabilities: { vision: true, trained_for_tool_use: true },
},
{
type: "llm",
key: "deepseek-r1",
display_name: "DeepSeek R1",
architecture: "deepseek",
loaded_instances: [],
max_context_length: 131_072,
capabilities: { vision: false, trained_for_tool_use: false },
},
{
type: "embedding",
key: "nomic-embed",
display_name: "Nomic Embed",
loaded_instances: [],
max_context_length: 2048,
},
],
}),
}),
),
(server) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin(server.url.origin)
const providerID = Provider.ID.make("lmstudio")
const gemma = yield* eventually(
catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")),
(model) => model !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
id: providerID,
name: "LM Studio",
activation: "enabled",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
expect(gemma).toMatchObject({
family: "gemma4",
name: "Gemma 4 26B A4B",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 16_384, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 131_072, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("refreshes the catalog when LM Studio models change", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models: Array<Record<string, unknown>> = []
return {
models,
server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }),
}
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("lmstudio")
yield* addPlugin(server.url.origin, "5 millis")
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
models.push({
type: "llm",
key: "qwen/qwen3-coder",
display_name: "Qwen 3 Coder",
architecture: "qwen3",
loaded_instances: [],
max_context_length: 65_536,
capabilities: { vision: false, trained_for_tool_use: true },
})
expect(
yield* eventually(
catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")),
(model) => model !== undefined,
),
).toMatchObject({ name: "Qwen 3 Coder" })
models.splice(0)
yield* eventually(catalog.provider.get(providerID), (provider) => provider === undefined)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live(
"discovers from configured endpoints with bearer authentication",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; path: string }> = []
const model = (key: string) => ({
type: "llm",
key,
display_name: key,
loaded_instances: [],
max_context_length: 32_768,
})
return {
requests,
initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
configured: Bun.serve({
port: 0,
fetch: (request) => {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
return Response.json({ models: [model("configured-model")] })
},
}),
}
}),
({ requests, initial, configured }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const catalog = yield* Catalog.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("lmstudio")
yield* addPlugin(initial.url.origin)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("initial-model")),
(model) => model !== undefined,
)
const baseURL = `${configured.url.origin}/proxy/v1`
yield* config.setEntries([configuration(baseURL, "secret")])
yield* bus.publish(Event.Updated, {})
yield* eventually(
catalog.model.get(providerID, Model.ID.make("configured-model")),
(model) => model !== undefined,
)
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" })
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
baseURL,
provider: "lmstudio",
apiKey: "secret",
})
requests.splice(0)
yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
yield* bus.publish(Event.Updated, {})
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
}),
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
),
10_000,
)
it.live("shares discovery requests across plugin instances", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests = { count: 0 }
return {
requests,
server: Bun.serve({
port: 0,
fetch: () => {
requests.count++
return Response.json({
models: [
{
type: "llm",
key: "shared-model",
display_name: "Shared Model",
loaded_instances: [],
max_context_length: 32_768,
},
],
})
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* addPlugin(server.url.origin)
yield* addPlugin(server.url.origin)
yield* eventually(
catalog.model.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")),
(model) => model !== undefined,
)
expect(requests.count).toBe(1)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models = [
{
type: "llm",
key: "discovered-model",
display_name: "Discovered Model",
loaded_instances: [],
max_context_length: 32_768,
},
]
return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) }
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("lmstudio")
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("lmstudio"), (integration) => {
integration.name = "LMStudio"
})
draft.method.update({
integrationID: Integration.ID.make("lmstudio"),
method: { type: "env", names: ["LMSTUDIO_API_KEY"] },
})
})
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "LMStudio"
provider.package = "aisdk:@ai-sdk/openai-compatible"
provider.integrationID = Integration.ID.make("lmstudio")
})
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID)
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(
catalog.model.get(providerID, Model.ID.make("discovered-model")),
(model) => model !== undefined,
)
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("lmstudio"), (integration) => {
integration.name = "Configured LM Studio"
})
draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } })
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
models.splice(0)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("static-model")),
(model) => model !== undefined,
)
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio"))
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
})
function configuration(baseURL: string, apiKey: string | null) {
return new Document({
type: "document",
info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
})
}
@@ -1,342 +0,0 @@
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { OllamaPlugin, make } from "@opencode-ai/core/plugin/provider/ollama"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
const decode = Schema.decodeUnknownSync(Info)
const decodeShowRequest = Schema.decodeUnknownSync(Schema.Struct({ model: Schema.String }))
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* make(origin, interval).effect(host)
})
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
describe("OllamaPlugin", () => {
it.live("discovers local completion models and native metadata", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ method: string; path: string; model?: string }> = []
return {
requests,
server: Bun.serve({
port: 0,
fetch: async (request) => {
const path = new URL(request.url).pathname
if (request.method === "GET") {
requests.push({ method: request.method, path })
return Response.json({
models: [
summary("gemma3:4b", "gemma-digest", "gemma3"),
summary("nomic-embed", "embed-digest"),
summary("removed-model", "removed-digest"),
],
})
}
const body = decodeShowRequest(await request.json())
requests.push({ method: request.method, path, model: body.model })
if (body.model === "removed-model") return new Response("Not found", { status: 404 })
return Response.json(
body.model === "gemma3:4b"
? {
capabilities: ["completion", "tools", "vision"],
model_info: { "gemma3.context_length": 131_072 },
}
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
)
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("ollama")
expect(OllamaPlugin.id).toBe("opencode.provider.ollama")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.ollama")
yield* addPlugin(server.url.origin)
const model = yield* eventually(
catalog.model.get(providerID, Model.ID.make("gemma3:4b")),
(item) => item !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
id: providerID,
name: "Ollama",
activation: "enabled",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { baseURL: `${server.url.origin}/v1`, provider: "ollama", apiKey: "" },
})
expect(model).toMatchObject({
modelID: "gemma3:4b",
name: "gemma3:4b",
family: "gemma3",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 131_072, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "gemma3:4b" })
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "nomic-embed" })
expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "removed-model" })
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("refreshes changed digests and retains inventory through transient failures", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { digest: "digest-1", context: 32_768, fail: false }
const requests = { tags: 0, show: 0 }
return {
state,
requests,
server: Bun.serve({
port: 0,
fetch: async (request) => {
if (request.method === "GET") {
requests.tags++
if (state.fail) return new Response("unavailable", { status: 503 })
return Response.json({ models: [summary("qwen3:8b", state.digest, "qwen3")] })
}
decodeShowRequest(await request.json())
requests.show++
return Response.json(
show({ family: "qwen3", capabilities: ["completion", "tools"], context: state.context }),
)
},
}),
}
}),
({ state, requests, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("ollama")
const modelID = Model.ID.make("qwen3:8b")
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 32_768)
yield* eventually(
Effect.sync(() => requests.tags),
(count) => count >= 2,
)
expect(requests.show).toBe(1)
state.digest = "digest-2"
state.context = 65_536
yield* eventually(catalog.model.get(providerID, modelID), (model) => model?.limit.context === 65_536)
expect(requests.show).toBe(2)
state.fail = true
yield* Effect.promise(() => Bun.sleep(30))
expect((yield* catalog.model.get(providerID, modelID))?.limit.context).toBe(65_536)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("replaces and restores the same-ID Models.dev provider", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models = [summary("discovered-model", "digest")]
return {
models,
server: Bun.serve({
port: 0,
fetch: async (request) => {
if (request.method === "GET") return Response.json({ models })
decodeShowRequest(await request.json())
return Response.json(show({ capabilities: ["completion"], context: 32_768 }))
},
}),
}
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("ollama")
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("ollama"), (integration) => {
integration.name = "Ollama"
})
draft.method.update({
integrationID: Integration.ID.make("ollama"),
method: { type: "env", names: ["OLLAMA_API_KEY"] },
})
})
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "Ollama"
provider.package = "aisdk:@ai-sdk/openai-compatible"
provider.integrationID = Integration.ID.make("ollama")
})
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
})
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(
catalog.model.get(providerID, Model.ID.make("discovered-model")),
(model) => model !== undefined,
)
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
models.splice(0)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("static-model")),
(model) => model !== undefined,
)
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeDefined()
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("ollama"))
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live(
"reloads layered endpoint and bearer authentication settings",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; method: string; path: string }> = []
return {
requests,
initial: Bun.serve({
port: 0,
fetch: async (request) => {
if (request.method === "GET")
return Response.json({ models: [summary("initial-model", "initial-digest")] })
decodeShowRequest(await request.json())
return Response.json(show({ capabilities: ["completion"], context: 4096 }))
},
}),
configured: Bun.serve({
port: 0,
fetch: async (request) => {
requests.push({
authorization: request.headers.get("authorization"),
method: request.method,
path: new URL(request.url).pathname,
})
if (request.method === "GET")
return Response.json({ models: [summary("configured-model", "configured-digest")] })
decodeShowRequest(await request.json())
return Response.json(show({ capabilities: ["completion", "vision"], context: 65_536 }))
},
}),
}
}),
({ requests, initial, configured }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const catalog = yield* Catalog.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("ollama")
yield* addPlugin(initial.url.origin)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("initial-model")),
(model) => model !== undefined,
)
const baseURL = `${configured.url.origin}/proxy/v1`
yield* config.setEntries([configuration({ baseURL, apiKey: "old" }), configuration({ apiKey: "secret" })])
yield* bus.publish(Event.Updated, {})
yield* eventually(
catalog.model.get(providerID, Model.ID.make("configured-model")),
(model) => model !== undefined,
)
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "GET", path: "/proxy/api/tags" })
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "POST", path: "/proxy/api/show" })
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
baseURL,
provider: "ollama",
apiKey: "secret",
})
requests.splice(0)
yield* config.setEntries([configuration({ baseURL, apiKey: "secret" }), configuration({ apiKey: null })])
yield* bus.publish(Event.Updated, {})
yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
expect(requests).toContainEqual({ authorization: null, method: "GET", path: "/proxy/api/tags" })
expect(requests).toContainEqual({ authorization: null, method: "POST", path: "/proxy/api/show" })
}),
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
),
10_000,
)
})
function summary(model: string, digest: string, family = "llama") {
return {
name: model,
model,
modified_at: "2026-01-01T00:00:00Z",
size: 1_000_000,
digest,
details: {
format: "gguf",
family,
families: [family],
parameter_size: "8B",
quantization_level: "Q4_K_M",
},
}
}
function show(input: { family?: string; capabilities: string[]; context: number }) {
const family = input.family ?? "llama"
return {
parameters: "temperature 0.7",
details: {
parent_model: "",
format: "gguf",
family,
families: [family],
parameter_size: "8B",
quantization_level: "Q4_K_M",
},
capabilities: input.capabilities,
model_info: {
"general.architecture": family,
[`${family}.context_length`]: input.context,
},
}
}
function configuration(settings: Record<string, string | null>) {
return new Document({
type: "document",
info: decode({ providers: { ollama: { settings } } }),
})
}
@@ -1,289 +0,0 @@
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Integration } from "@opencode-ai/core/integration"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { make, VLLMPlugin } from "@opencode-ai/core/plugin/provider/vllm"
import { Provider } from "@opencode-ai/core/provider"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
const decode = Schema.decodeUnknownSync(Info)
const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* make(origin, interval).effect(host)
})
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
const remoteModel = (id: string, max_model_len = 32_768, owned_by = "vllm") => ({
id,
object: "model",
created: 1,
owned_by,
root: id,
parent: null,
max_model_len,
permission: [],
})
describe("VLLMPlugin", () => {
it.live("waits for readiness and discovers official vLLM model metadata", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { healthy: false, models: 0 }
return {
state,
server: Bun.serve({
port: 0,
fetch: (request) => {
const path = new URL(request.url).pathname
if (path === "/health") return new Response(null, { status: state.healthy ? 200 : 503 })
state.models++
return Response.json({
object: "list",
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
})
},
}),
}
}),
({ state, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("vllm")
expect(VLLMPlugin.id).toBe("opencode.provider.vllm")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.vllm")
yield* addPlugin(server.url.origin, "5 millis")
yield* Effect.promise(() => Bun.sleep(20))
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
expect(state.models).toBe(0)
state.healthy = true
const model = yield* eventually(
catalog.model.get(providerID, Model.ID.make("Qwen/Qwen3-Coder")),
(item) => item !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
id: providerID,
name: "vLLM",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { baseURL: `${server.url.origin}/v1`, provider: "vllm", apiKey: "" },
activation: "enabled",
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
expect(model).toMatchObject({
modelID: "Qwen/Qwen3-Coder",
name: "Qwen/Qwen3-Coder",
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 65_536, output: 0 },
})
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("refreshes inventory while retaining the last success through transient failures", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { failing: false, models: [remoteModel("first-model")] }
return {
state,
server: Bun.serve({
port: 0,
fetch: (request) => {
if (state.failing) return new Response(null, { status: 503 })
if (new URL(request.url).pathname === "/health") return new Response()
return Response.json({ object: "list", data: state.models })
},
}),
}
}),
({ state, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("vllm")
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(catalog.model.get(providerID, Model.ID.make("first-model")), (model) => model !== undefined)
state.failing = true
state.models = [remoteModel("second-model")]
yield* Effect.promise(() => Bun.sleep(30))
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeDefined()
expect(yield* catalog.model.get(providerID, Model.ID.make("second-model"))).toBeUndefined()
state.failing = false
yield* eventually(
catalog.model.get(providerID, Model.ID.make("second-model")),
(model) => model !== undefined,
)
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("replaces and restores same-ID Models.dev entries after an empty success", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const models = [remoteModel("discovered-model")]
return {
models,
server: Bun.serve({
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/health"
? new Response()
: Response.json({ object: "list", data: models }),
}),
}
}),
({ models, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("vllm")
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("vllm"), (integration) => {
integration.name = "vLLM"
})
draft.method.update({
integrationID: Integration.ID.make("vllm"),
method: { type: "env", names: ["VLLM_API_KEY"] },
})
})
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "vLLM"
provider.package = "aisdk:@ai-sdk/openai-compatible"
provider.integrationID = Integration.ID.make("vllm")
provider.activation = "auto"
})
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
})
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(
catalog.model.get(providerID, Model.ID.make("discovered-model")),
(model) => model !== undefined,
)
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
models.splice(0)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("static-model")),
(model) => model !== undefined,
)
expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined()
expect(yield* integrations.get(Integration.ID.make("vllm"))).toBeDefined()
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("vllm"))
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live(
"reloads layered custom endpoint and bearer authentication settings",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; path: string }> = []
return {
requests,
initial: Bun.serve({
port: 0,
fetch: (request) =>
new URL(request.url).pathname === "/health"
? new Response()
: Response.json({ object: "list", data: [remoteModel("initial-model")] }),
}),
configured: Bun.serve({
port: 0,
fetch: (request) => {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
if (new URL(request.url).pathname === "/proxy/health") return new Response()
return Response.json({ object: "list", data: [remoteModel("configured-model")] })
},
}),
}
}),
({ requests, initial, configured }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const catalog = yield* Catalog.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("vllm")
yield* addPlugin(initial.url.origin)
yield* eventually(
catalog.model.get(providerID, Model.ID.make("initial-model")),
(model) => model !== undefined,
)
const baseURL = `${configured.url.origin}/proxy/v1`
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "secret" })])
yield* bus.publish(Event.Updated, {})
yield* eventually(
catalog.model.get(providerID, Model.ID.make("configured-model")),
(model) => model !== undefined,
)
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/health" })
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/v1/models" })
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
baseURL,
provider: "vllm",
apiKey: "secret",
})
requests.splice(0)
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "next-secret" })])
yield* bus.publish(Event.Updated, {})
yield* eventually(
catalog.provider.get(providerID),
(provider) => provider?.settings?.apiKey === "next-secret",
)
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/health" })
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/v1/models" })
}),
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
),
10_000,
)
})
function configuration(settings: { baseURL?: string; apiKey?: string }) {
return new Document({
type: "document",
info: decode({ providers: { vllm: { settings } } }),
})
}
+1 -219
View File
@@ -4,10 +4,8 @@ import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { and, eq } from "drizzle-orm"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -17,8 +15,6 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
@@ -32,16 +28,6 @@ const it = testEffect(
],
),
)
// Default bus: durable payloads are not retained (`events.persist` off).
const itVolatile = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.log", () => {
@@ -74,43 +60,6 @@ describe("Session.log", () => {
}),
)
it.effect("accepts a cursor exactly at the aggregate head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const items = Array.from(
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(0) }])
}),
)
it.effect("fails with SeqUnavailable when the cursor is beyond the aggregate head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const errors = yield* Effect.forEach([1, 10], (after) =>
Effect.flip(Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(after) }))),
)
expect(errors.map((error) => error._tag)).toEqual([
"Session.SeqUnavailableError",
"Session.SeqUnavailableError",
])
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.after : undefined))).toEqual([
Event.Seq.make(1),
Event.Seq.make(10),
])
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.head : undefined))).toEqual([
Event.Seq.make(0),
Event.Seq.make(0),
])
}),
)
it.effect("fails with NotFound for an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -119,109 +68,6 @@ describe("Session.log", () => {
}),
)
it.effect("orders live ephemeral deltas after their durable start", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
const fiber = yield* session
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true, ephemeral: true })
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "hello",
})
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
"log.synced",
"session.text.started",
"session.text.delta",
])
}),
)
it.effect("never includes ephemeral events in replay", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "not retained",
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
text: "complete",
})
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, ephemeral: true })))
expect(items.map((item) => item.type)).toEqual([
"session.created",
"session.text.started",
"session.text.ended",
"log.synced",
])
}),
)
it.effect("keeps the default follow stream durable-only", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
const assistantMessageID = SessionMessage.ID.create()
const fiber = yield* session
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
delta: "filtered",
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: created.id,
assistantMessageID,
ordinal: 0,
text: "complete",
})
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
"log.synced",
"session.text.started",
"session.text.ended",
])
}),
)
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
Effect.gen(function* () {
const GapEvent = Bus.durable({
@@ -241,33 +87,12 @@ describe("Session.log", () => {
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
expect(
items.map((item): number | string | undefined =>
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
),
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
).toEqual([3, 4, "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
}),
)
it.effect("fails with SeqUnavailable when the replay range is only partially retained", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "pruned" })
yield* db
.delete(EventTable)
.where(and(eq(EventTable.aggregate_id, created.id), eq(EventTable.seq, 1)))
.run()
const error = yield* Effect.flip(
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(error._tag).toBe("Session.SeqUnavailableError")
}),
)
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -296,46 +121,3 @@ describe("Session.log", () => {
}),
)
})
describe("Session.log without retained events", () => {
itVolatile.effect("accepts a cursor exactly at the head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "at head" })
const items = Array.from(
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(1) })),
)
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
}),
)
itVolatile.effect("fails with SeqUnavailable for a cursor behind the head", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "behind head" })
const error = yield* Effect.flip(
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
)
expect(error._tag).toBe("Session.SeqUnavailableError")
expect(error._tag === "Session.SeqUnavailableError" ? error.head : undefined).toEqual(Event.Seq.make(1))
}),
)
itVolatile.effect("replays nothing but stays live for a cursorless read", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "cursorless" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
}),
)
})
@@ -1,87 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Event } from "@opencode-ai/schema/event"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.snapshot", () => {
it.effect("returns an empty projected session at its aggregate watermark", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const created = yield* sessions.create({ location })
expect(yield* sessions.snapshot({ sessionID: created.id })).toEqual({
session: created,
children: [],
inbox: [],
messages: [],
seq: Event.Seq.make(0),
})
}),
)
it.effect("returns the most recent messages in aggregate order", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* sessions.create({ location })
yield* Effect.forEach(["first", "second", "third"], (text) =>
bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text }),
)
const snapshot = yield* sessions.snapshot({ sessionID: created.id, recent: 2 })
expect(snapshot.messages.map((message) => (message.type === "synthetic" ? message.text : message.type))).toEqual([
"second",
"third",
])
expect(snapshot.seq).toBe(Event.Seq.make(3))
}),
)
it.effect("keeps rows and watermark consistent during concurrent publication", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* sessions.create({ location })
const publish = Effect.forEach(
Array.from({ length: 40 }, (_, index) => index + 1),
(index) => bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: String(index) }),
)
const read = Effect.forEach(Array.from({ length: 40 }), () => sessions.snapshot({ sessionID: created.id }))
const [, snapshots] = yield* Effect.all([publish, read], { concurrency: "unbounded" })
snapshots.forEach((snapshot) => {
expect(snapshot.messages).toHaveLength(snapshot.seq)
expect(
snapshot.messages.map((message) => (message.type === "synthetic" ? Number(message.text) : -1)),
).toEqual(Array.from({ length: snapshot.seq }, (_, index) => index + 1))
})
}),
)
})
-34
View File
@@ -782,40 +782,6 @@ describe("ShellTool", () => {
),
)
if (!isWindows) {
it.live("settles a shell terminated by an external signal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const settled = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-external-signal"),
)
const shellID = settled.metadata?.shellID
expect(typeof shellID).toBe("string")
if (typeof shellID !== "string") return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(typeof info.pid).toBe("number")
if (info.pid === undefined) return
process.kill(-info.pid, "SIGTERM")
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
expect(result._tag).toBe("Some")
if (result._tag === "Some") expect(result.value.status).toBe("exited")
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
}
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
-12
View File
@@ -1,6 +1,5 @@
import { Schema } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { Event } from "@opencode-ai/schema/event"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
@@ -36,17 +35,6 @@ export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>(
{ httpApiStatus: 409 },
) {}
export class SeqUnavailableError extends Schema.TaggedErrorClass<SeqUnavailableError>()(
"SeqUnavailableError",
{
sessionID: Schema.String,
after: Event.Seq,
head: Schema.optional(Event.Seq),
message: Schema.String,
},
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
+3 -29
View File
@@ -18,7 +18,6 @@ import {
InvalidRequestError,
MessageNotFoundError,
ServiceUnavailableError,
SeqUnavailableError,
SessionBusyError,
SessionNotFoundError,
SkillNotFoundError,
@@ -220,30 +219,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.snapshot", "/api/session/:sessionID/snapshot", {
params: { sessionID: Session.ID },
query: {
recent: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
},
success: Schema.Struct({
data: Schema.Struct({
session: Session.Info,
children: Schema.Array(Session.Info),
inbox: Schema.Array(SessionInbox.Info),
messages: Schema.Array(SessionMessage.Info),
seq: Event.Seq,
}),
}).annotate({ identifier: "SessionSnapshotResponse" }),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.snapshot",
summary: "Snapshot session state",
description: "Retrieve projected session state and its aggregate sequence from one consistent read.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.remove", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
@@ -658,18 +633,17 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
query: {
after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional),
follow: BooleanFromString.pipe(Schema.optional),
ephemeral: BooleanFromString.pipe(Schema.optional),
},
success: HttpApiSchema.StreamSse({
data: Schema.Union([SessionEvent.All, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
data: Schema.Union([SessionEvent.Durable, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
}),
error: [SessionNotFoundError, SeqUnavailableError],
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.log",
summary: "Read the session log",
description:
"Experimental session event log. Replay is durable-only; follow mode can opt into live ephemeral events.",
"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
}),
),
)
+60 -53
View File
@@ -1,7 +1,7 @@
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect } from "effect"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
@@ -13,7 +13,6 @@ import {
InvalidCursorError,
MessageNotFoundError,
ServiceUnavailableError,
SeqUnavailableError,
SessionBusyError,
SessionNotFoundError,
SkillNotFoundError,
@@ -27,23 +26,16 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const sessionNotFound = (error: Session.NotFoundError) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
})
const messageDecodeFailed = (error: Session.MessageDecodeError) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
effect.pipe(
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.InboxConflictError",
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
@@ -139,8 +131,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
),
}
}),
@@ -171,19 +180,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.snapshot",
Effect.fn(function* (ctx) {
return {
data: yield* session
.snapshot({ sessionID: ctx.params.sessionID, recent: ctx.query.recent })
.pipe(
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
),
}
}),
)
.handle(
"session.remove",
Effect.fn(function* (ctx) {
@@ -652,8 +648,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", sessionNotFound),
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
),
),
)
}),
),
}
}),
@@ -744,25 +757,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.log",
Effect.fn(function* (ctx) {
return yield* session
.openLog({
sessionID: ctx.params.sessionID,
after: ctx.query.after,
follow: ctx.query.follow,
ephemeral: ctx.query.ephemeral,
})
.pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
? sessionNotFound(error)
: new SeqUnavailableError({
sessionID: error.sessionID,
after: error.after,
head: error.head,
message: `Session log is unavailable after sequence ${error.after}`,
}),
),
)
yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return session
.log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow })
.pipe(Stream.orDie)
}),
)
.handle(
@@ -13,7 +13,13 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "tab_scroll",
title: "Remember tab scroll",
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
},
]
export function DialogExperiments() {
const config = useConfig()
+2 -6
View File
@@ -208,11 +208,6 @@ export function Prompt(props: PromptProps) {
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
onCleanup(
data.session.failures.listen((failure) => {
toast.show({ title: "Prompt rejected", message: failure.reason, variant: "error" })
}),
)
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const history = usePromptHistory()
const stash = usePromptStash()
@@ -457,6 +452,7 @@ export function Prompt(props: PromptProps) {
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
@@ -1296,7 +1292,7 @@ export function Prompt(props: PromptProps) {
return false
}
}
const error = await data.session
const error = await client.api.session
.prompt({
sessionID,
text: inputText,
+12 -8
View File
@@ -23,7 +23,7 @@ import {
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabNumberLabel,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
@@ -426,7 +426,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => Math.max(2, String(items().length).length)
const numberWidth = () => 2
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
@@ -657,14 +657,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
<text
width={numberWidth() + 1}
width={numberWidth()}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{sessionTabNumberLabel(index()).padStart(numberWidth())}
{sessionTabShortcutLabel(index())}
</text>
<text
width={titleWidth()}
@@ -1040,7 +1040,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
@@ -1140,8 +1141,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
<text width={1} selectable={false}>
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
+1 -1
View File
@@ -178,7 +178,7 @@ export const Definitions = {
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("<leader>return", "Queue prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
+1 -1
View File
@@ -163,7 +163,7 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("<leader>return", "Queue prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
+2 -2
View File
@@ -1,4 +1,4 @@
import { createEngineData } from "@opencode-ai/client/solid"
import { createData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -10,7 +10,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const client = useClient()
const data = createEngineData({
const data = createData({
api: () => client.api,
event: client.event,
connection: client.connection,
@@ -7,8 +7,10 @@ export type SessionTabUnread = "activity" | "error"
export const NEW_SESSION_TAB_TITLE = "New session"
export function sessionTabNumberLabel(index: number) {
return String(index + 1)
export function sessionTabShortcutLabel(index: number) {
if (index >= 0 && index < 9) return String(index + 1)
if (index === 9) return "0"
return "·"
}
export function sessionTabDetail(
@@ -87,6 +87,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
renderer.off("blur", onBlur)
})
createEffect(() => {
if (config.experimental?.tab_scroll === true) return
scrollAnchors.clear()
})
function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
-1
View File
@@ -1050,7 +1050,6 @@ export function createPromptState(input: PromptInput): PromptState {
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
palette: true,
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{
id: "session.queued_prompts",
title: "View queued prompts",
group: "Prompt",
group: "Session",
run: openQueuedMenu,
},
],
+9 -14
View File
@@ -429,6 +429,7 @@ export function Session(props: { verticalTabsWidth: number }) {
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
}
function updateAwayFromBottom() {
if (config.experimental?.tab_scroll !== true) return
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
awayTimer = undefined
@@ -439,7 +440,7 @@ export function Session(props: { verticalTabsWidth: number }) {
})
}
function saveScrollAnchor() {
if (!isAwayFromBottom()) {
if (config.experimental?.tab_scroll !== true || !isAwayFromBottom()) {
sessionTabs.setScrollAnchor(sessionID, undefined)
return
}
@@ -456,7 +457,7 @@ export function Session(props: { verticalTabsWidth: number }) {
else sessionTabs.setScrollAnchor(sessionID, undefined)
}
function restoreScrollPosition() {
const anchor = sessionTabs.scrollAnchor(sessionID)
const anchor = config.experimental?.tab_scroll === true ? sessionTabs.scrollAnchor(sessionID) : undefined
const index = anchor ? boundaries().indexOf(anchor.messageID) : -1
if (!anchor || index === -1) {
scroll.scrollTo(scroll.scrollHeight)
@@ -1062,7 +1063,7 @@ export function Session(props: { verticalTabsWidth: number }) {
{
title: "View queued prompts",
id: "session.queued_prompts",
group: "Prompt",
group: "Session",
enabled: queuedPrompts().length > 0,
run: openQueuedPrompts,
},
@@ -1194,21 +1195,15 @@ export function Session(props: { verticalTabsWidth: number }) {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={
latestHovered() ? theme.background.action.primary.focused : theme.background.action.primary.default
}
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
<text
fg={latestHovered() ? theme.text.default : theme.text.subdued}
onMouseOver={() => setLatestHovered(true)}
onMouseOut={() => setLatestHovered(false)}
onMouseUp={toBottom}
>
<text fg={latestHovered() ? theme.text.action.primary.focused : theme.text.action.primary.default}>
Jump to latest
</text>
</box>
Latest
</text>
</Show>
</box>
<box flexShrink={0}>
-12
View File
@@ -213,25 +213,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const input = (event: SessionInboxEnqueued) => {
if (
event.data.sessionID === sessionID() &&
event.data.item.delivery !== "queue" &&
(event.data.item.type === "user" ||
(event.data.item.type === "synthetic" && event.data.item.payload.description?.trim()))
)
appendMessage(event.data.inboxID)
}
const delivery = (event: { data: { sessionID: string; inboxID: string; delivery: "steer" | "queue" } }) => {
if (event.data.sessionID !== sessionID()) return
if (event.data.delivery === "steer") return appendMessage(event.data.inboxID)
setRows(
produce((draft) => {
const index = draft.findIndex((row) => row.type === "message" && row.messageID === event.data.inboxID)
if (index !== -1) draft.splice(index, 1)
}),
)
}
const subscriptions = [
data.on("session.inbox.enqueued", input),
data.on("session.inbox.delivery.changed", delivery),
data.on("session.compaction.started", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
}),
+12 -3
View File
@@ -2592,7 +2592,18 @@ test("reconciles active session forms when the event stream reconnects", async (
test("settles pending tools when a live failure arrives", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const calls = createFetch((url) => {
if (url.pathname === "/api/session/session-1/message/msg_model_1")
return json({
data: {
id: "msg_model_1",
type: "model-switched",
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
model: { id: "model-1", providerID: "provider-1", variant: "high" },
time: { created: 0 },
},
})
}, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
@@ -2619,7 +2630,6 @@ test("settles pending tools when a live failure arrives", async () => {
try {
await mounted
await sync.session.message.sync("session-1")
emitEvent(events, {
id: "evt_agent_1",
created: 0,
@@ -2635,7 +2645,6 @@ test("settles pending tools when a live failure arrives", async () => {
data: {
sessionID: "session-1",
model: { id: "model-1", providerID: "provider-1", variant: "high" },
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
},
})
emitEvent(events, {
-1
View File
@@ -107,7 +107,6 @@ test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
["prompt.paste", "input_paste"],
["prompt.queue", "prompt_queue"],
["session.delete", "session_delete"],
["session.list", "session_list"],
["agent.list", "agent_list"],
@@ -13,7 +13,7 @@ import {
sessionTabComplete,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabNumberLabel,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
@@ -25,8 +25,8 @@ describe("session tabs", () => {
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
})
test("labels tabs by ordinal", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabNumberLabel(index))).toEqual([
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
"2",
"3",
@@ -36,9 +36,9 @@ describe("session tabs", () => {
"7",
"8",
"9",
"10",
"11",
"12",
"0",
"·",
"·",
])
})
@@ -239,23 +239,6 @@ test("stores session tabs for the current working directory by default", async (
}
})
test("keeps scroll anchors for open session tabs", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.tabs.setScrollAnchor("first", { messageID: "msg_1", screenY: -3 })
expect(setup.tabs.scrollAnchor("first")).toEqual({ messageID: "msg_1", screenY: -3 })
setup.tabs.close("first")
await wait(() => setup.tabs.tabs().every((tab) => tab.sessionID !== "first"))
expect(setup.tabs.scrollAnchor("first")).toBeUndefined()
} finally {
await setup.destroy()
}
})
test("only the foreground TUI mutates unread state", async () => {
await using temporary = await tmpdir()
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
+1 -79
View File
@@ -16,9 +16,6 @@ export function createEventStream() {
const encoder = new TextEncoder()
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
const pending: Uint8Array[] = []
const logs = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>()
const logSeq = new Map<string, number>()
const logHistory = new Map<string, Array<{ readonly seq: number; readonly event: unknown }>>()
const response = (
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
queued: Uint8Array[],
@@ -30,8 +27,7 @@ export function createEventStream() {
start(controller) {
current = controller
controllers.add(controller)
const values = Array.isArray(initial) ? initial : initial ? [initial] : []
for (const value of values) controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
},
cancel() {
@@ -57,47 +53,13 @@ export function createEventStream() {
return {
emit(event: OpenCodeEvent) {
send(v2, pending, event)
const sessionID =
"durable" in event
? event.durable.aggregateID
: "sessionID" in event.data && typeof event.data.sessionID === "string"
? event.data.sessionID
: undefined
if (!sessionID) return
const seq = (logSeq.get(sessionID) ?? 0) + 1
const item = "durable" in event ? { ...event, durable: { ...event.durable, seq } } : event
if ("durable" in event) {
logSeq.set(sessionID, seq)
logHistory.set(sessionID, [...(logHistory.get(sessionID) ?? []), { seq, event: item }])
}
const controllers = logs.get(sessionID)
if (controllers) send(controllers, [], item)
},
v2() {
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
},
log(sessionID: string, after: number) {
const controllers = logs.get(sessionID) ?? new Set<ReadableStreamDefaultController<Uint8Array>>()
logs.set(sessionID, controllers)
return response(
controllers,
[],
[
...(logHistory.get(sessionID) ?? []).filter((entry) => entry.seq > after).map((entry) => entry.event),
{ type: "log.synced", aggregateID: sessionID, seq: logSeq.get(sessionID) ?? 0 },
],
)
},
seq(sessionID: string) {
return logSeq.get(sessionID) ?? 0
},
disconnect() {
for (const controller of v2) controller.close()
v2.clear()
for (const controllers of logs.values()) {
for (const controller of controllers) controller.close()
controllers.clear()
}
},
}
}
@@ -106,8 +68,6 @@ export type FetchHandler = (url: URL, request: Request) => Response | undefined
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
const session = [] as URL[]
const sessionEvents = events ?? createEventStream()
const snapshots = new Map<string, number>()
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
@@ -115,44 +75,6 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
const overridden = await override?.(url, request)
if (overridden) return overridden
if (url.pathname === "/api/event" && events) return events.v2()
const snapshot = url.pathname.match(/^\/api\/session\/([^/]+)\/snapshot$/)
if (snapshot) {
const sessionID = decodeURIComponent(snapshot[1])
const count = snapshots.get(sessionID) ?? 0
snapshots.set(sessionID, count + 1)
const read = async (path: string, fallback: unknown) => {
const response = await override?.(new URL(path, url), new Request(new URL(path, url)))
if (!response) return fallback
const body = await response.json()
if (typeof body !== "object" || body === null || !("data" in body)) return fallback
return body.data
}
const children = await read(`/api/session?parentID=${encodeURIComponent(sessionID)}`, [])
const messages = await read(`/api/session/${encodeURIComponent(sessionID)}/message`, [])
return json({
data: {
session: await read(`/api/session/${encodeURIComponent(sessionID)}`, {
id: sessionID,
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}),
children: Array.isArray(children)
? children.filter(
(child) =>
typeof child === "object" && child !== null && "parentID" in child && child.parentID === sessionID,
)
: [],
inbox: await read(`/api/session/${encodeURIComponent(sessionID)}/inbox`, []),
messages: Array.isArray(messages) ? messages.toReversed() : [],
seq: count === 0 ? 0 : sessionEvents.seq(sessionID),
},
})
}
const log = url.pathname.match(/^\/api\/experimental\/session\/([^/]+)\/log$/)
if (log) return sessionEvents.log(decodeURIComponent(log[1]), Number(url.searchParams.get("after") ?? 0))
if (
[
+2 -4
View File
@@ -981,8 +981,7 @@ test("direct footer steers the oldest queued prompt from an empty composer", asy
try {
await app.renderOnce()
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(steered).toEqual([])
app.mockInput.pressEnter()
@@ -1035,8 +1034,7 @@ test("direct footer rejects local commands submitted with the queue shortcut", a
try {
await app.renderOnce()
await app.mockInput.typeText("/settings ")
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressEnter()
app.mockInput.pressEnter({ meta: true })
await Bun.sleep(0)
expect(submitted).toEqual([])
expect(statuses).toContain("this prompt cannot be queued")
+1 -1
View File
@@ -22,7 +22,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
})
test("preserves shared config while resolving independent Mini defaults", async () => {
+11 -29
View File
@@ -17,7 +17,7 @@
}
[data-slot="animated-number-digit"] {
display: inline-grid;
display: inline-block;
width: 1ch;
height: 1em;
line-height: 1em;
@@ -41,12 +41,19 @@
mask-repeat: no-repeat;
}
[data-slot="animated-number-static"],
[data-slot="animated-number-strip"] {
grid-area: 1 / 1;
display: inline-flex;
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-strip"][data-animating="false"] {
transition-duration: 0ms;
}
[data-slot="animated-number-static"],
[data-slot="animated-number-cell"] {
display: inline-flex;
align-items: center;
@@ -55,24 +62,6 @@
height: 1em;
line-height: 1em;
}
[data-slot="animated-number-digit"][data-animating="true"] [data-slot="animated-number-static"] {
visibility: hidden;
}
[data-slot="animated-number-strip"] {
display: inline-flex;
flex-direction: column;
margin-top: calc(var(--animated-number-offset, 10) * -1em);
transition-property: margin-top;
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"][data-animating="false"] [data-slot="animated-number-strip"] {
transition-duration: 0ms;
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -82,12 +71,5 @@
[data-component="animated-number"] [data-slot="animated-number-strip"] {
transition-duration: 0ms;
visibility: hidden;
}
[data-component="animated-number"]
[data-slot="animated-number-digit"][data-animating]
[data-slot="animated-number-static"] {
visibility: visible;
}
}
@@ -43,10 +43,10 @@ function Digit(props: { value: number; direction: 1 | -1 }) {
)
return (
<span data-slot="animated-number-digit" data-animating={animating() ? "true" : "false"}>
<span data-slot="animated-number-static">{props.value}</span>
<span data-slot="animated-number-digit">
<span
data-slot="animated-number-strip"
data-animating={animating() ? "true" : "false"}
onTransitionEnd={() => {
setState("animating", false)
setState("step", (value) => normalize(value) + 10)
@@ -152,112 +152,6 @@ provider and model configuration. An unknown variant fails model resolution inst
### Local models
#### Ollama
OpenCode automatically discovers language models from an Ollama server listening on its default address,
`http://127.0.0.1:11434`. Discovered models use the `ollama` provider ID and Ollama's model name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "ollama/gemma3:4b",
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.ollama"]`.
For a different host or port, configure Ollama's OpenAI-compatible base URL. Models are still discovered through the
native Ollama API at the same path prefix:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"ollama": {
"settings": {
"baseURL": "http://127.0.0.1:5678/v1",
"apiKey": "{env:OLLAMA_API_KEY}",
},
},
},
}
```
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
#### LM Studio
OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default
address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "lmstudio/google/gemma-4-26b-a4b",
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.lmstudio"]`.
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"lmstudio": {
"settings": {
"baseURL": "http://127.0.0.1:5678/v1",
"apiKey": "{env:LMSTUDIO_API_KEY}",
},
},
},
}
```
Omit `apiKey` when LM Studio authentication is disabled.
#### vLLM
OpenCode automatically discovers models from a vLLM server listening on its default address, `http://127.0.0.1:8000`.
Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct",
}
```
OpenCode checks vLLM's `/health` endpoint and refreshes `/v1/models` in the background. It uses the reported
`max_model_len` as the context limit and only includes model cards owned by `vllm`. Discovered vLLM models advertise
text input and output, but not vision or tools. Tool calling is conservative because vLLM enables it with server-level
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
discovery with `"plugins": ["-opencode.provider.vllm"]`.
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"vllm": {
"settings": {
"baseURL": "http://127.0.0.1:9000/v1",
"apiKey": "{env:VLLM_API_KEY}",
},
},
},
}
```
Omit `apiKey` when authentication is disabled. Path-prefixed proxy URLs are supported; for example,
`https://example.com/vllm/v1` checks `/vllm/health` and discovers `/vllm/v1/models`.
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
```jsonc title="opencode.jsonc"
@@ -267,7 +161,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
"providers": {
"local": {
"name": "Local server",
"package": "@opencode-ai/ai/providers/openai-compatible",
"package": "aisdk:@ai-sdk/openai-compatible",
"settings": {
"baseURL": "http://127.0.0.1:1234/v1",
},