Compare commits

..

54 Commits

Author SHA1 Message Date
Kit Langton 726a11ba4a test(client): review pass on sync-engine test suite
Two parallel reviews (quality + tests-as-spec) over the engine tests,
treating them as first-class code. Accuracy fixes to catalog claims,
fixture honesty, and typecheck coverage:

- typecheck now covers the sync-engine tests and scripts via
  tsconfig.test.json (old test files with pre-existing errors excluded);
  fixes the type errors this surfaced (fixture stream signal param,
  readonly snapshot arrays, invalid assistant literal)
- FakeSessionServer: file header stating the real-fold modeling claim,
  honors the abort signal, heal() so the sim reset cannot drift from the
  faults record, seq() so tests stop leaning on events.length, threads
  the constructor time into the snapshot, fixes AsyncQueue falsy trap
- laws: file header, law 10 numbering for the refresh-race test, vacuous
  law 2 wait replaced with engine.ready(), law 1/2 story comments,
  robust comment stripping in law 3
- sim: header, chaos distribution + drain-phase comments, heal(),
  ses_-prefixed session IDs everywhere
- divergence catalog: corrected engine-side claims (live reordering is
  ruled out by the ordered log, not the cursor check; activity resync
  wording; law 10 citation), header notes the raw-protocol retry test,
  per-call delta event IDs
- engine-data: header linking it to the laws, SNAPSHOT_RECENT named
  constant, error-DTO source-of-truth comment
2026-08-18 23:03:29 -04:00
Kit Langton d31fbab722 test(client): catalog legacy data layer divergence states 2026-08-18 22:42:38 -04:00
Kit Langton 4fcffdd05e docs: record future directions from architecture retrospective 2026-08-18 22:24:13 -04:00
Kit Langton a060459985 docs: clarify engine vs legacy size comparison 2026-08-18 22:14:44 -04:00
Kit Langton 16a45957fd test(client): cover server outages and restarts in sync engine suite 2026-08-18 22:11:51 -04:00
Kit Langton 90832ffe8d refactor(client): simplify engine render and store diff 2026-08-18 21:58:35 -04:00
Kit Langton 6cbf607849 refactor(client): clone only changed subtrees on engine publish 2026-08-18 21:29:12 -04:00
Kit Langton 378d46c076 chore(client): add session sync streaming benchmark 2026-08-18 21:29:11 -04:00
Kit Langton e0d4a0374c chore(client): regenerate on rebased protocol 2026-08-18 20:56:08 -04:00
Kit Langton f6f8606155 docs: record cursor honesty contract 2026-08-18 20:44:57 -04:00
Kit Langton ef31823a6f fix(client): recover from lossy log attach via snapshot 2026-08-18 20:44:57 -04:00
Kit Langton 4a42f4a724 fix(core): refuse log cursors without retained replay 2026-08-18 20:44:57 -04:00
Kit Langton ce27f07f15 fix(client): preserve minted prompt IDs 2026-08-18 20:44:57 -04:00
Kit Langton 80587d936f docs: wiring complete, regression dispositions 2026-08-18 20:44:57 -04:00
Kit Langton 09772d89d5 fix(tui): restore data-layer guarantees under engine wiring 2026-08-18 20:44:57 -04:00
Kit Langton 4f07025067 fix(client): restore engine refresh guarantees 2026-08-18 20:44:57 -04:00
Kit Langton f253bdaeef feat(tui): wire engine data layer 2026-08-18 20:44:57 -04:00
Kit Langton 93c24cafa7 feat(client): engine-backed data layer 2026-08-18 20:44:57 -04:00
Kit Langton 0963826120 docs: record lane status 2026-08-18 20:44:56 -04:00
Kit Langton c5df2e5c28 refactor(core): simplify snapshot and log changes 2026-08-18 20:44:56 -04:00
Kit Langton 13f0cdd0ea feat(core): session.log follow includes ephemeral events 2026-08-18 20:44:32 -04:00
Kit Langton 5887911c26 feat(core): typed seq-unavailable on session.log 2026-08-18 20:44:01 -04:00
Kit Langton 59bc653465 feat(protocol): session.snapshot endpoint 2026-08-18 20:44:01 -04:00
Kit Langton 8a4c14aed5 refactor(client): simplify sync engine 2026-08-18 20:43:47 -04:00
Kit Langton 15aef5f0f2 test(client): seeded chaos sim 2026-08-18 20:43:47 -04:00
Kit Langton 9b87120db6 test(client): six laws 2026-08-18 20:43:47 -04:00
Kit Langton af6407a1b1 feat(client): engine core 2026-08-18 20:43:47 -04:00
Kit Langton e4ff762483 feat(client): session fold 2026-08-18 20:43:47 -04:00
Kit Langton a00822b06e feat(core): Session.snapshot 2026-08-18 20:43:47 -04:00
Kit Langton 58c33f6870 docs: deletion inventory at adoption 2026-08-18 20:43:21 -04:00
Kit Langton 82d13c6715 docs: outbox ack contract, feature-grouped commits, opt-in ephemeral stream 2026-08-18 20:43:21 -04:00
Kit Langton 6c1d066a65 docs: drop adopt-strictness (S2), renumber lanes 2026-08-18 20:43:21 -04:00
Kit Langton 9b8801a3c6 docs: commit-by-commit implementation lanes, snapshot recent param 2026-08-18 20:43:21 -04:00
Kit Langton 8dad59cf99 docs: delivery is a parallel build for local comparison, not a staged migration 2026-08-18 20:43:21 -04:00
Kit Langton a023ece71f docs: snapshot-only entry contract, compaction/revert/fork, ambient bus filter (S4) 2026-08-18 20:43:21 -04:00
Kit Langton 2b5cc0090a docs: single session stream, overlay semantics, mid-stream reconnect 2026-08-18 20:43:21 -04:00
Kit Langton fc6b9e0dde docs: fold prior-art findings into snapshot decision 2026-08-18 20:43:20 -04:00
Kit Langton ab535c3875 docs: decide snapshot endpoint shape (tentative) 2026-08-18 20:43:20 -04:00
Kit Langton 4ca2f9dd83 docs: sync engine plan outline 2026-08-18 20:43:20 -04:00
Kyle Altendorf 044d04df06 fix(cli): preserve clean build manifest (#43287) 2026-08-18 16:32:10 -05:00
Shoubhit Dash 4b9d89e943 refactor(core): move image config into state (#43090) 2026-08-18 21:07:27 +00:00
Kit Langton 5ff6bb87cf fix(core): coalesce queued compactions (#43292) 2026-08-18 21:04:02 +00:00
opencode-agent[bot] 511b4556a2 fix(tui): navigate nested subagents (#43290)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 23:02:45 +02:00
Shoubhit Dash cb39ea1136 fix(core): prefer websocket with request hooks (#43234) 2026-08-19 02:20:57 +05:30
Shoubhit Dash ff9452bf03 refactor(core): move formatter config into state (#43087) 2026-08-19 02:20:24 +05:30
Aiden Cline 97265f8ac5 fix(ai): ignore orphaned Anthropic tool deltas (#43289) 2026-08-18 15:42:31 -05:00
Dax Raad 56e66656b8 fix(ci): limit cli publish build time 2026-08-18 15:43:47 -04:00
opencode-agent[bot] 02f3f3cb3e test(core): remove flaky webfetch checks (#43278)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-08-18 20:26:26 +02:00
Dax b0c3a16ead feat(plugin): expose server plugin status 2026-08-18 14:22:32 -04:00
Filip 594c395576 fix(tui): restore MCP sign-in flow (#43274) 2026-08-18 20:02:10 +02:00
Dax b7402c264d fix(core): reduce noisy tracing spans
Reduce low-value tracing emitted during parallel tool execution and suppress per-statement SQL spans.
2026-08-18 13:42:50 -04:00
Dax 3f79699bce feat(core): persist websearch provider selection (#43268) 2026-08-18 17:32:08 +00:00
Dax 67fe76057e feat(cli): configure service environment (#43269) 2026-08-18 13:27:44 -04:00
opencode-agent[bot] bac474aaa0 fix(tui): scope session picker to active location (#43264)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-18 17:01:15 +00:00
135 changed files with 6283 additions and 1280 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/plugin": patch
"@opencode-ai/core": patch
---
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
+1
View File
@@ -82,6 +82,7 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
+336
View File
@@ -0,0 +1,336 @@
# Session Sync: Two Architectures
How the legacy client data layer (`createData`) and the session sync engine
(`createEngineData`) keep a client's view of a session in sync with the
server — and why they feel so different to reason about.
Both produce the same thing: a fine-grained reactive Solid store the TUI (and
web app) render from. They differ in *where correctness lives*.
- **legacy** — correctness = the union of ~36 event handlers each being right
- **engine** — correctness = one pure fold + a handful of testable laws
---
## At a glance
| | Legacy `createData` | Engine `createEngineData` |
|---|---|---|
| Source of truth | the store itself, patched per event | server's durable log, folded |
| Ingestion | ambient `/api/event` SSE, all sessions | snapshot + per-session `/log` (durable + ephemeral, seq-cursored) |
| State shape | one mutable store, edited in place | immutable `EngineState`, new value per event |
| Writes | 20 mutation sites, 3 inline fetches interleaved | 2 mutation sites, zero interleaved I/O |
| Recovery | 8 refetch heuristics, band-aid invalidations | one seq watermark; gaps are loud (`SeqUnavailable` → re-snapshot) |
| Optimism | `evt_``msg_` ID rewriting, per-case | outbox of intents with client-minted IDs, acked by durable echo |
| Testability | drive the store, assert the store | law-test the pure fold; chaos-sim the engine |
```tree
src/solid
├── engine/
│ ├── fold.ts 580 lines · pure fold — (state, durableEvent) → state
│ └── engine.ts 498 lines · outbox · overlay · reconnect · render
├── engine-data.ts 271 lines · Solid adapter — identity diff + clone boundary
└── data.ts 1,527 lines · legacy layer (still serves the web app)
```
Size, honestly: the engine's 1,349 lines replace only `data.ts`'s *session
sync* portion (~654 lines) — the rest of `data.ts` covers other domains
(projects, locations, VCS, …) and still runs underneath as a shim. So the
engine is ~2× the code it replaces; the extra lines are the behaviors legacy
doesn't have at all (snapshot/cursor recovery, outbox, reconnect proofs).
---
## How a token delta flows
The hottest path in the system: one streamed text token.
### Legacy — a surgical poke
The actual handler, one of 36 case arms:
```typescript title="src/solid/data.ts" caption="In-place proxy mutation — done in one step"
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(
message.assistant(draft, index, event.data.assistantMessageID),
)
if (match) match.text += event.data.delta // ← mutates shared store state in place
})
return
```
Cost: **0.3µs**. Nothing else moves. It is also the whole design: every event
type gets its own hand-written poke at shared mutable state.
### Engine — a value transition
The same token becomes a state transition through a pure pipeline:
```ts
// engine.ts — the sync loop receives the delta and replaces state
publish({ ...state, overlay: applyOverlay(state.overlay, event) })
const publish = (next: EngineState) => {
const previous = state
state = next
// identity guard: synced flips and stale replays render nothing
if (
next.folded === previous.folded &&
next.outbox === previous.outbox &&
next.overlay === previous.overlay
)
return
const view = render(state) // pure
listeners.forEach((listener) => listener(view)) // → adapter
}
function render(state: EngineState): SessionView {
const base = renderBase(state.folded, state.outbox) // ← WeakMap cache hit while streaming
return {
...state.folded,
session: usageSession(state.folded, state.overlay.get("usage")), // ← cache hit
messages: applyOverlayToMessages(base.messages, state.overlay), // remaps only touched messages
pending: base.pending,
}
}
```
```ts
// engine-data.ts — the adapter diffs consecutive views by identity
const update = (sessionID: string, view: SessionView) => {
const previous = rendered.get(sessionID)
rendered.set(sessionID, view)
// the fold is a persistent structure: unchanged parts keep their references,
// so reference inequality *is* the change detector
for (let index = 0; index < view.messages.length; index++)
if (view.messages[index] !== previous.messages[index])
setViews(sessionID, "messages", index, reconcile(clone(view.messages[index])))
// clone only what changed — the store never aliases engine state
}
```
Cost: **3.5µs**. Ten times the legacy poke, 0.035% of a core at 100 tok/s —
and the price buys the properties below.
---
## The legacy architecture
```mermaid
flowchart TD
SSE["/api/event — ambient SSE, every session"] --> HE["handleEvent(event)<br/>switch · 36 case arms in one function"]
HE --> M["setStore(produce)<br/>mutate in place · 20 sites"]
HE --> I["sync.invalidate()<br/>refetch on next read · 8 heuristics"]
HE --> F["api().fetch …<br/>fetch right now · 3 inline"]
M --> S[("one shared<br/>mutable store")]
I --> S
F --> S
S --> UI["TUI / web app"]
```
An outline of what it takes to be correct here:
- every case arm must patch exactly the right path in the store
- event-vs-fetch races must be reasoned about per case
(e.g. the `session.created` band-aid: skip racy initial reads so live
events can win over a stale fetch)
- a missed or misordered event **silently desyncs** — there is no watermark
to notice a gap, so recovery is "invalidate and hope the next read heals it"
- optimistic sends rewrite `evt_*` IDs into `msg_*` IDs inline
None of this is dumb code — it's each problem solved locally, at the site
where it hurt. The cost is that the invariants live in 36 places at once.
## The engine architecture
```mermaid
flowchart TD
SNAP["GET /snapshot<br/>session · inbox · messages · seq"] --> ES
LOG["GET /log?after=seq&follow&ephemeral<br/>replay ▸ log.synced ▸ live tail"] --> ES
subgraph ES["EngineState — immutable, replaced per event"]
FOLD["folded = fold(folded, durableEvent)<br/>pure · seq-ordered"]
OV["overlay = applyOverlay(overlay, event)<br/>ephemeral deltas"]
OB["outbox = [...intents]<br/>optimistic, client-minted IDs"]
end
ES --> R["render(state) → SessionView<br/>persistent structure: unchanged parts<br/>keep their references across versions"]
R --> D["identity diff →<br/>clone changed subtrees only"]
D --> ST[("Solid store")]
ST --> UI["TUI / web app"]
```
Mini outline of the modules:
- **`fold.ts`** — `(state, durableEvent) → state`. No I/O, no clock,
no randomness. Same events in, same state out, on any client.
- **`engine.ts`** — the machine around the fold: snapshot hydration,
log tailing, reconnect, outbox, overlay, `render`. One serial sender.
- **`engine-data.ts`** — the Solid adapter: identity diff, the clone
boundary (reconcile mutates in place, so engine state is never aliased
into the store), and the legacy-API shim.
### The write path — optimistic prompt
```mermaid
sequenceDiagram
participant UI as TUI
participant E as Engine
participant S as Server
participant L as Durable Log
UI->>E: submit(text)
E->>E: outbox += intent { id: client-minted msg ID }
Note over UI,E: view renders the pending row immediately
E->>S: POST /session/:id/prompt { id }
S->>L: session.inbox.enqueued { inboxID: id }
L-->>E: durable echo on the log stream
E->>E: fold(echo) inserts the real row + acks the intent
Note over UI,E: same publish — no flicker, no duplicate, ever
```
Admission is exactly-once *by construction*: the ID is the dedupe key, so a
retried POST after a lost response cannot double-admit.
### Recovery
```mermaid
flowchart TD
DC["disconnect"] --> RC["reconnect"]
RC --> T["GET /log?after=lastSeq"]
T -->|"rows retained for (after, head]"| RP["replay ▸ log.synced ▸ live tail"]
T -->|"gap in retention"| SU["SeqUnavailable — loud, never silent"]
SU --> RS["re-snapshot"]
RS --> OBX["resend unacked outbox"]
```
The server *proves* the replay covers the cursor range or refuses. The
client treats a `log.synced` marker past its folded seq the same way. There
is no silent-desync state.
---
## What the laws pin down
The fold's purity makes these mechanically testable
(`sync-engine-laws.test.ts`, plus a seeded chaos simulation):
1. idempotent admission under lost responses
2. durable echo determinism
3. fold purity (no I/O, replay-stable)
4. submission-order admission
5. multi-client convergence to the server fold
6. failure atomicity (a rejected intent vanishes with its optimistic row)
7. lossy-history recovery (pruned retention → re-snapshot, nothing lost)
8. attach-gap recovery (marker past folded seq → re-snapshot)
9. outage recovery (server down during recovery → retry until it returns)
The legacy layer can't state most of these, because "the fold" is smeared
across 36 case arms and the store itself.
---
## Performance
Benchmark: `packages/client` → `bun run bench:sync`
(200-message transcript, 2,000 streamed deltas, median of 7).
| per token delta | |
|---|---|
| legacy | 0.3µs |
| engine, first draft | 240µs — full-view `structuredClone` + reconcile per event |
| engine, shipped | **3.5µs** (69×) |
How the immutable path got cheap — each step exploits the same fact, that
the fold is a *persistent structure* (unchanged parts keep identity):
1. never clone the whole view; **diff consecutive views by reference** and
clone only changed subtrees, each clone at exactly one store path
2. `render` preserves references for everything an event didn't touch
(memoized durable base, overlay-touched messages only)
3. skip publish entirely when folded/outbox/overlay are identity-unchanged
4. recursive plain-JSON clone instead of `structuredClone`'s serializer
Remaining gap at scale: ~10ns per message per delta of identity walking
(0.24% CPU at 2,000 messages, 100 tok/s). Memory is flat in both layers.
---
## Honest tradeoffs
- **Outbox is in-memory** — process death loses unsent intents (admission
stays exactly-once; nothing duplicates).
- **One log SSE per open session** — fine for the TUI, needs multiplexing
thought for HTTP/1.1 web contexts.
- **State held twice** — the fold's state plus the store's cloned mirror.
Bounded, measured flat, and it *is* the aliasing safety boundary.
- **Ambient event stream still unfiltered** while the legacy shim handles
non-session domains.
- **~1.9× the raw lines touched** — but inverted concentration: the legacy
layer's complexity is *distributed* (36 arms × interleaved I/O × races);
the engine's is *concentrated* in one pure function you can hold in your
head, and law-test.
The trade in one sentence: the legacy layer optimizes for the cheapest
possible patch per event; the engine optimizes for the cheapest possible
*proof* that the client shows what the server knows.
---
## Future directions (design notes)
Each of these replaces a boundary around the engine, not the engine itself —
its `snapshot`/`stream`/`submit` transport seam and pure fold stay put.
### 1. One fold — tables as indexes, not truth
Objection: the server saves into SQL *tables*, not one state value, so how
can server and client share a fold? Answer: distinguish the aggregate's
**client-visible state** (what snapshots and views show) from the server's
**query indexes** (session lists, search). The shared fold defines only the
former. Two server shapes make it work:
- persist the event log as truth and compute snapshot responses by running
the shared fold (cached / checkpointed every N events), or
- persist the fold *output* transactionally with each event append and serve
snapshots from it.
Either way SQL tables become **derived indexes computed from fold output** —
free to take any shape, unable to disagree with what clients render.
Convergence stops being a law and becomes a construction. Near-term bridge:
the fold/projector equivalence test against the real embedded server (replay
recorded event streams, assert projected snapshot ≡ client fold).
### 2. Quark as the reactive layer
`~/code/open-source/quark` — explicit identity/equivalence reactivity:
values are immutable snapshots by law, `Keyed` collections split structure
from value publication, computeds cut off on reference equality and receive
their previous value, `Layout` compiles per-field diff bitmasks. Solid
adapter (`useValue`/`useSlot`/`KeyedFor`) plus an experimental direct
OpenTUI JSX runtime (`quark-opentui-jsx`).
The fit is exact: the engine's `SessionView` *is* Quark's input contract —
immutable, keyed by message ID, unchanged parts reference-stable. With
`Keyed.set(view.messages)` per publish, the entire adapter apparatus
(clone boundary, identity diff, `StoreSessionView`) disappears, because
nothing downstream mutates stored values — the one Solid behavior
(`reconcile` mutating in place) that forced it all.
Path: engine → Quark `Keyed` → Solid adapter inside the existing TUI
(incremental), with the OpenTUI JSX runtime as the eventual Solid-free
endgame. Caveats: month-old private prototype, flat keyed collections only
(fine for transcripts), needs productionizing.
### 3. Multiplexed transport
One WebSocket / RPC stream carrying `subscribe { aggregate, after }` frames
instead of one SSE per open session — per-aggregate cursors over a single
connection, ambient events become just another subscription (subsumes S4).
Only the transport implementation changes.
### 4. Smaller notes
- durable outbox (SQLite/IndexedDB spool) for offline-safe writes
- typed overlay part addresses instead of string keys
- windowed views: bounded recent fold window + paged history
+352
View File
@@ -0,0 +1,352 @@
# 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.
@@ -905,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "input_json_delta" && event.index !== undefined) {
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
const result = ToolStream.appendExisting(
ADAPTER,
state.tools,
@@ -1010,6 +1010,34 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores tool input deltas without a matching tool start", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
},
{ type: "content_block_stop", index: 0 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.text).toBe("Hello")
expect(response.toolCalls).toEqual([])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("settles pending tool calls at message_stop", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -7,6 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useData } from "@/context/server"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -101,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
})
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
@@ -6,6 +6,7 @@ import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useMcpToggle } from "@/context/mcp"
import { pluginLabel } from "@/utils/plugin"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -44,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const plugins = createMemo<PluginRowItem[]>(() =>
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
)
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
@@ -6,6 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { pluginLabel } from "@/utils/plugin"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -38,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
+8
View File
@@ -0,0 +1,8 @@
import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+1 -1
View File
@@ -42,8 +42,8 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0"
},
"devDependencies": {
+24 -3
View File
@@ -275,15 +275,36 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("get", {
description: "Get service configuration",
params: { key: Argument.string("key").pipe(Argument.optional) },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
Spec.make("set", {
description: "Set service configuration",
params: { key: Argument.string("key"), value: Argument.string("value") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
value: Argument.string("value").pipe(
Argument.withDescription("Setting value or environment variable name"),
),
nestedValue: Argument.string("env-value").pipe(
Argument.withDescription("Environment variable value"),
Argument.optional,
),
},
}),
Spec.make("unset", {
description: "Unset service configuration",
params: { key: Argument.string("key") },
params: {
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
name: Argument.string("name").pipe(
Argument.withDescription("Environment variable name"),
Argument.optional,
),
},
}),
],
}),
@@ -1,6 +1,6 @@
import { EOL } from "node:os"
import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
@@ -14,11 +14,18 @@ export default Runtime.handler(
const endpoint = found ?? (yield* Service.ensure(options))
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
if (plugins.length === 0) {
process.stdout.write("No plugins loaded" + EOL)
return
}
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
process.stdout.write(plugins.map(name).join(EOL) + EOL)
}),
)
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
@@ -7,6 +7,8 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) {
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
process.stdout.write(
(yield* ServiceConfig.get(Option.getOrUndefined(input.key), Option.getOrUndefined(input.name))) + EOL,
)
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) {
yield* ServiceConfig.set(input.key, input.value)
yield* ServiceConfig.set(input.key, input.value, Option.getOrUndefined(input.nestedValue))
}),
)
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@@ -6,6 +6,6 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) {
yield* ServiceConfig.unset(input.key)
yield* ServiceConfig.unset(input.key, Option.getOrUndefined(input.name))
}),
)
+37 -8
View File
@@ -15,10 +15,11 @@ export const Info = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password"] as const
const keys = ["hostname", "port", "password", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -76,7 +77,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password") return key
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -104,6 +105,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
env: (yield* read()).env,
command: [
...selfCommand(),
"serve",
@@ -141,12 +143,14 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu
return next
})
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* read()
return JSON.stringify(safe, null, 2)
}
switch (configKey(key)) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -157,12 +161,19 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
case "password": {
return yield* password()
}
case "env": {
const env = (yield* read()).env ?? {}
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
}
}
throw new Error(`Unknown service config key: ${key}`)
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
switch (configKey(key)) {
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string, nestedValue?: string) {
const selected = configKey(key)
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -180,11 +191,20 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* password(value)
return
}
case "env": {
if (nestedValue === undefined) throw new Error("Usage: opencode service set env <key> <value>")
yield* Service.stop(yield* options())
const existing = yield* read()
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
return
}
}
})
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
switch (configKey(key)) {
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string, name?: string) {
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
@@ -203,6 +223,15 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "env": {
if (name === undefined) throw new Error("Usage: opencode service unset env <key>")
yield* Service.stop(yield* options())
const existing = yield* read()
const { [name]: _removed, ...env } = existing.env ?? {}
const { env: _existingEnv, ...rest } = existing
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
return
}
}
})
+38
View File
@@ -60,6 +60,44 @@ test("local channel stores service config with the local service filename", asyn
}
})
test("service config manages environment variables", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-env-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
try {
await Effect.runPromise(
ServiceConfig.set("env", "OPENCODE_SERVICE_ENV_TEST", "configured").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(
await Effect.runPromise(
ServiceConfig.get("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
),
).toBe("configured")
expect(
(
await Effect.runPromise(
ServiceConfig.options().pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)),
)
).env,
).toEqual({ OPENCODE_SERVICE_ENV_TEST: "configured" })
await Effect.runPromise(
ServiceConfig.unset("env", "OPENCODE_SERVICE_ENV_TEST").pipe(
Effect.provide(layer),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
+2 -1
View File
@@ -30,7 +30,8 @@
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
"bench:sync": "bun script/bench-session-sync.ts",
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
+69
View File
@@ -0,0 +1,69 @@
# Session sync streaming performance
## Goal
Keep the engine data layer's per-token-delta cost negligible next to the
legacy `createData` path, so the sync engine's immutable fold/render
architecture is not a streaming CPU regression.
## Benchmark
```
cd packages/client
bun run bench:sync
```
Env knobs: `BENCH_TRANSCRIPT` (messages, default 200), `BENCH_DELTAS`
(default 2000), `BENCH_RUNS` (default 7, median after 1 warmup).
## Metrics
- `engine_deltas_ms` (primary) — wall clock to stream `BENCH_DELTAS` text
deltas into the active assistant message.
- `legacy_deltas_ms` — same scenario through legacy `createData`.
- `*_hydrate_ms`, `*_retained_mb` — secondary.
## Files in scope
- `src/solid/engine-data.ts` — store adapter (`update`, clone boundary)
- `src/solid/engine/engine.ts``render`, `applyOverlayToMessages`
## Experiment log (200 messages, 2000 deltas)
Baseline: engine 240µs/delta vs legacy 0.3µs/delta.
1. KEEP — drop full-view `structuredClone` per publish → 41ms (11.6×).
Follow-up: raw pass-through was unsound (reconcile mutates the store's
backing tree in place, corrupting engine state / aliased clones).
2. KEEP — identity-diff consecutive views (the fold is a persistent
structure) and clone only changed subtrees, each clone used at exactly
one store path → 67ms sound (7.1× vs baseline).
3. KEEP — reference-preserving `render`: identity-stable `pending` when the
outbox is empty, skip pending-derived message work when nothing pending,
remap only overlay-touched messages → 16.3µs/delta.
4. KEEP — skip `legacy.session.remember` clone when `view.session` is
identity-unchanged → 9.4µs/delta.
5. KEEP — hand-rolled recursive clone instead of `structuredClone` for the
small per-event subtrees → 3.5µs/delta.
Final: 240 → 3.5µs/delta (69×) at 200 messages; 23.6µs/delta at 2000
messages (remaining cost is the O(n) identity walk, ~10ns/message/delta).
Simplify pass (no benchmark movement, closes paths the scenario misses):
`render` memoizes its durable base on fold/outbox identity and the
usage-adjusted session on the usage entry, so identity preservation holds
even with a live usage overlay or pending steers/outbox items; `publish`
skips render and notify when folded/outbox/overlay are identity-unchanged
(synced flips, stale replays). The bench scenario streams with an empty
outbox and no usage entry, so those paths need the memo caches for cover.
## Dead ends
- WeakMap-memoized structural-sharing clone: unsound. `reconcile` merges
nodes in place, so a memoized clone reachable from two store paths (or a
later view) gets corrupted; caught by the TUI `updates session location
when moved` test via fold state that aliases `previous.location`.
- Microtask coalescing of publishes: each stream item already arrives on
its own microtask, so `queueMicrotask` batching collapses nothing; real
frame coalescing (~16ms timer) left unexplored as unnecessary at current
numbers.
@@ -0,0 +1,225 @@
// Benchmark: legacy createData vs engine createEngineData session sync.
//
// Scenarios per layer:
// hydrate — populate a session with TRANSCRIPT messages
// deltas — stream DELTAS text deltas into the active assistant message
// retained — heap retained by the populated layer (post-GC)
//
// Run from packages/client: bun run bench:sync
// Emits METRIC lines (median of RUNS after 1 warmup).
import { heapStats } from "bun:jsc"
import { createRoot } from "solid-js"
import { createData } from "../src/solid/data"
import type { CreateDataInput } from "../src/solid/data"
import { createEngineData } from "../src/solid/engine-data"
import type { OpenCodeEvent, SessionMessageInfo } from "../src/promise"
const TRANSCRIPT = Number(process.env.BENCH_TRANSCRIPT ?? 200)
const DELTAS = Number(process.env.BENCH_DELTAS ?? 2000)
const RUNS = Number(process.env.BENCH_RUNS ?? 7)
const sessionID = "ses_bench"
const assistantID = `msg_a${TRANSCRIPT - 1}`
function transcript(): SessionMessageInfo[] {
return Array.from({ length: TRANSCRIPT }, (_, index): SessionMessageInfo => {
const created = 1_700_000_000_000 + index
if (index % 2 === 0)
return { id: `msg_u${index}`, type: "user", text: `user message ${index} ${"lorem ".repeat(40)}`, time: { created } }
return {
id: `msg_a${index}`,
type: "assistant",
time: index === TRANSCRIPT - 1 ? { created } : { created, completed: created + 1 },
agent: "build",
content: [{ type: "text", text: `assistant reply ${index} ${"ipsum ".repeat(40)}` }],
} as SessionMessageInfo
})
}
function sessionInfo() {
return {
id: sessionID,
projectID: "proj_bench",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "bench",
location: { directory: "/bench" },
}
}
const textStarted = (seq: number) => ({
id: "evt_start",
created: 3,
type: "session.text.started" as const,
durable: { aggregateID: sessionID, seq, version: 1 },
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
})
const textDelta = (index: number) => ({
id: `evt_d${index}`,
created: 4,
type: "session.text.delta" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta: "x" },
})
const textEnded = (seq: number) => ({
id: "evt_end",
created: 5,
type: "session.text.ended" as const,
durable: { aggregateID: sessionID, seq, version: 1 },
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, text: "END" },
})
type Layer = {
hydrate: () => Promise<void>
dispatch: (event: Record<string, unknown>) => void
finalText: () => string | undefined
dispose: () => void
}
type MessageReader = {
session: { message: { get: (sessionID: string, messageID: string) => SessionMessageInfo | undefined } }
}
function lastText(data: MessageReader) {
const message = data.session.message.get(sessionID, assistantID)
const part = message?.type === "assistant" ? message.content.findLast((item) => item.type === "text") : undefined
return part?.type === "text" ? part.text : undefined
}
function legacyLayer(): Layer {
let handler: ((event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) | undefined
const messages = transcript()
const api = {
session: { get: async () => sessionInfo() },
message: { list: async () => ({ data: messages.toReversed(), cursor: {} }) },
} as unknown as ReturnType<CreateDataInput["api"]>
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/bench",
event: {
on: () => () => {},
listen(next) {
handler = next
return () => {}
},
},
connection: { status: () => "connected" },
})
return {
async hydrate() {
await data.session.sync(sessionID)
await data.session.message.sync(sessionID)
},
dispatch(event) {
handler?.({ name: event.type as OpenCodeEvent["type"], details: event as unknown as OpenCodeEvent })
},
finalText: () => lastText(data),
dispose,
}
})
}
function engineLayer(): Layer {
const queue: Array<Record<string, unknown>> = []
let wake: (() => void) | undefined
const api = {
session: {
snapshot: async () => ({
session: sessionInfo(),
children: [],
inbox: [],
messages: transcript(),
seq: 10,
}),
async *log() {
yield { type: "log.synced", aggregateID: sessionID, seq: 10 }
while (true) {
const item = queue.shift()
if (item) {
yield item
continue
}
await new Promise<void>((resolve) => {
wake = resolve
})
}
},
prompt: async () => ({}),
},
} as unknown as ReturnType<CreateDataInput["api"]>
return createRoot((dispose) => {
const data = createEngineData({
api: () => api,
directory: "/bench",
event: { on: () => () => {}, listen: () => () => {} },
connection: { status: () => "connected" },
})
return {
async hydrate() {
await data.session.sync(sessionID)
},
dispatch(event) {
queue.push(event)
wake?.()
wake = undefined
},
finalText: () => lastText(data),
dispose,
}
})
}
async function settle(check: () => boolean) {
for (let attempt = 0; attempt < 10_000; attempt++) {
if (check()) return
await new Promise((resolve) => setTimeout(resolve, 0))
}
throw new Error("scenario did not settle")
}
async function scenario(make: () => Layer) {
const layer = make()
const hydrateStart = performance.now()
await layer.hydrate()
const hydrate = performance.now() - hydrateStart
const deltaStart = performance.now()
layer.dispatch(textStarted(11))
for (let index = 0; index < DELTAS; index++) layer.dispatch(textDelta(index))
layer.dispatch(textEnded(12))
await settle(() => layer.finalText() === "END")
const deltas = performance.now() - deltaStart
Bun.gc(true)
const retained = heapStats().heapSize
layer.dispose()
return { hydrate, deltas, retained }
}
function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
async function measure(name: string, make: () => Layer) {
await scenario(make) // warmup
Bun.gc(true)
const baseline = heapStats().heapSize
const runs: Awaited<ReturnType<typeof scenario>>[] = []
for (let run = 0; run < RUNS; run++) runs.push(await scenario(make))
const hydrate = median(runs.map((run) => run.hydrate))
const deltas = median(runs.map((run) => run.deltas))
const retained = median(runs.map((run) => run.retained)) - baseline
console.log(
`${name}: hydrate ${hydrate.toFixed(2)}ms deltas ${deltas.toFixed(2)}ms (${((deltas * 1000) / DELTAS).toFixed(1)}µs/delta) retained ${(retained / 1024 / 1024).toFixed(2)}MB`,
)
console.log(`METRIC ${name}_hydrate_ms=${hydrate.toFixed(3)}`)
console.log(`METRIC ${name}_deltas_ms=${deltas.toFixed(3)}`)
console.log(`METRIC ${name}_retained_mb=${(retained / 1024 / 1024).toFixed(3)}`)
}
console.log(`transcript=${TRANSCRIPT} deltas=${DELTAS} runs=${RUNS}`)
await measure("legacy", legacyLayer)
await measure("engine", engineLayer)
+174 -85
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"
@@ -139,36 +139,46 @@ 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 }
export type Endpoint5_6Output = void
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, 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_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_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_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_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_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
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 = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -179,10 +189,10 @@ export type Endpoint5_12Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -195,19 +205,19 @@ export type Endpoint5_13Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_16Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -216,97 +226,98 @@ export type Endpoint5_15Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_18Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, 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_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_19Input = {
export type Endpoint5_20Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
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_20Output = Session.Revert
export type SessionRevertStageOperation<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 SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, 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_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_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxCancelOperation<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 SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
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 SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_29Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
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> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, 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_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_31Input = {
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 = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
readonly ephemeral?: boolean | undefined
}
export type Endpoint5_31Output =
export type Endpoint5_32Output =
| (
| {
readonly id: Event.ID
@@ -894,24 +905,101 @@ export type Endpoint5_31Output =
}
}
)
| {
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_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
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_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, 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 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 variables: { readonly [x: string]: string } }
export type Endpoint5_35Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, 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_36Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
export type Endpoint5_36Output = void
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -920,6 +1008,7 @@ 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>
+91 -80
View File
@@ -86,6 +86,8 @@ import type {
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint5_36Input,
Endpoint5_36Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -350,48 +352,56 @@ 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.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
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.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).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_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -410,8 +420,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -432,16 +442,16 @@ 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_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -458,16 +468,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
@@ -477,13 +487,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -493,27 +503,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
),
)
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.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -521,62 +523,70 @@ 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.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).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.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.cancel"]({ 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.queue"]({ 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_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_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
query: { after: input["after"], follow: input["follow"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
@@ -584,29 +594,29 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
preserveEffect<Endpoint5_36Output>()(
raw["session.environment"]({
params: { sessionID: input["sessionID"] },
payload: { variables: input["variables"] },
@@ -620,29 +630,30 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
export: Endpoint5_3(raw),
active: Endpoint5_4(raw),
get: Endpoint5_5(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),
environment: Endpoint5_35(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),
environment: Endpoint5_36(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
+1 -1
View File
@@ -71,7 +71,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({
try: () => {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
@@ -20,6 +20,8 @@ import type {
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
SessionSnapshotInput,
SessionSnapshotOutput,
SessionRemoveInput,
SessionRemoveOutput,
SessionForkInput,
@@ -514,6 +516,18 @@ 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>(
{
@@ -843,9 +857,9 @@ export function make(options: ClientOptions) {
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
query: { after: input["after"], follow: input["follow"] },
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
declaredStatuses: [404, 409, 401, 400],
empty: false,
},
requestOptions,
+105 -49
View File
@@ -10,7 +10,11 @@ export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "local"; path: string }
| { type: "sdk" }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -196,6 +200,10 @@ export type ProviderRequest = {
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PluginInfo =
| { id: string; source: PluginSource; status: "active"; tui: boolean }
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
export type TokenUsageInfo = {
input: number
output: number
@@ -504,51 +512,6 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: 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 SessionTextDelta = {
id: string
created: number
@@ -594,6 +557,51 @@ 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,7 +1994,15 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionLogItem =
| SessionEventDurable
| SessionUsageUpdated
| SessionTextDelta
| SessionReasoningDelta
| SessionToolInputDelta
| SessionToolProgress
| SessionCompactionDelta
| EventLogSynced
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
@@ -1996,6 +2012,16 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
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 }
@@ -2210,6 +2236,16 @@ 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
@@ -3282,6 +3318,13 @@ 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
@@ -3920,8 +3963,21 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
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"]
}
export type SessionLogOutput = SessionLogItem
+1 -1
View File
@@ -51,7 +51,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
return spawnServiceContender(command, args)
return spawnServiceContender(command, args, options.env)
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
+10 -2
View File
@@ -10,8 +10,16 @@ export type ServiceContender = {
const stderrLimit = 8 * 1024
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
export function spawnServiceContender(
command: string,
args: ReadonlyArray<string>,
env?: Readonly<Record<string, string>>,
): ServiceContender {
const child = spawn(command, args, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, ...env },
})
let error: Error | undefined
let closed = false
let stderr = Buffer.alloc(0)
+2
View File
@@ -28,6 +28,8 @@ export type EnsureReason = "missing" | "version-mismatch"
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Environment variables added to the inherited service process environment. */
readonly env?: Readonly<Record<string, string>>
/** Called once before spawning a new service process. */
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
+274
View File
@@ -0,0 +1,274 @@
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">
// Solid's setStore path types reject readonly arrays. The store only ever
// holds deep clones it owns, so a mutable mirror of the engine view is safe.
// Shallow on purpose: a recursive mutable type either flattens tuples or
// exceeds TS instantiation depth on the recursive metadata JSON types.
type StoreSessionView = {
-readonly [Key in keyof Engine.SessionView]: Engine.SessionView[Key] extends ReadonlyArray<infer Item>
? Item[]
: Engine.SessionView[Key]
}
// Engine data is plain JSON, so a recursive copy beats structuredClone's
// serialization overhead on the small per-event subtrees the adapter clones.
function clone<T>(value: T): T {
if (value === null || typeof value !== "object") return value
if (Array.isArray(value)) return value.map(clone) as T
const copy: Record<string, unknown> = {}
for (const key in value) copy[key] = clone(value[key as keyof T])
return copy as T
}
const ambientSessionEvents = new Set<OpenCodeEvent["type"]>([
"session.created",
"session.deleted",
"session.renamed",
"session.execution.started",
"session.execution.succeeded",
"session.execution.failed",
"session.execution.interrupted",
])
/** How many recent messages a session snapshot fetch requests. */
export const SNAPSHOT_RECENT = 200
export function createEngineTransport(api: () => SessionApi): Engine.SessionTransport {
return {
snapshot(sessionID) {
return api().snapshot({ sessionID, recent: SNAPSHOT_RECENT })
},
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, StoreSessionView>>({})
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
// Reconcile mutates the store's backing tree in place, so engine state must
// never be aliased into it and every clone must appear at exactly one store
// path. The fold is a persistent structure — successive views share
// references for everything unchanged — so diff the previous view by
// identity and deep-clone only the changed subtrees. (A full-view
// structuredClone per publish dominated the streaming hot path.) New
// SessionView fields must be diffed here or they never propagate past the
// first publish.
const rendered = new Map<string, Engine.SessionView>()
const update = (sessionID: string, view: Engine.SessionView) => {
const previous = rendered.get(sessionID)
rendered.set(sessionID, view)
const sessionChanged = view.session !== previous?.session
batch(() => {
if (!previous) setViews(sessionID, clone(view) as StoreSessionView)
else {
if (sessionChanged) setViews(sessionID, "session", reconcile(clone(view.session)))
if (view.children !== previous.children)
setViews(sessionID, "children", reconcile(clone(view.children) as StoreSessionView["children"]))
if (view.inbox !== previous.inbox)
setViews(sessionID, "inbox", reconcile(clone(view.inbox) as StoreSessionView["inbox"]))
if (view.pending !== previous.pending)
setViews(sessionID, "pending", reconcile(clone(view.pending) as StoreSessionView["pending"]))
if (view.seq !== previous.seq) setViews(sessionID, "seq", view.seq)
if (view.active !== previous.active) setViews(sessionID, "active", view.active)
if (view.deleted !== previous.deleted) setViews(sessionID, "deleted", view.deleted)
if (view.messages !== previous.messages) {
// Per-index writes can grow the store array but never shrink it, so
// a shorter messages list falls back to a whole-array reconcile.
if (view.messages.length < previous.messages.length)
setViews(sessionID, "messages", reconcile(clone(view.messages) as StoreSessionView["messages"]))
else
for (let index = 0; index < view.messages.length; index++)
if (view.messages[index] !== previous.messages[index])
setViews(sessionID, "messages", index, reconcile(clone(view.messages[index])))
}
}
if (sessionChanged) {
const current = legacy.session.get(sessionID)
if (!current || current.time.updated <= view.session.time.updated) {
legacy.session.remember(clone(view.session))
}
}
if (families.has(sessionID) && view.children !== previous?.children) {
view.children.forEach((child) => legacy.session.remember(clone(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(clone(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>
+498
View File
@@ -0,0 +1,498 @@
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) => {
const previous = state
state = next
// Views derive from folded/outbox/overlay only, so synced flips and stale
// replays (where the fold returns its input) need no render or notify.
if (next.folded !== previous.folded || next.outbox !== previous.outbox || next.overlay !== previous.overlay) {
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 })
},
}
}
// Render runs per ephemeral event, so everything an event did not touch must
// keep its reference: the adapter diffs consecutive views by identity to
// decide what to write into the reactive store. Both caches key on persistent
// inputs (a fold, outbox, or usage entry keeps its identity until it actually
// changes), so per-delta renders only reapply the overlay.
export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">): SessionView {
const base = renderBase(state.folded, state.outbox)
return {
...state.folded,
session: usageSession(state.folded, state.overlay.get("usage")),
messages: applyOverlayToMessages(base.messages, state.overlay),
pending: base.pending,
}
}
const bases = new WeakMap<SessionFoldState, ReturnType<typeof buildBase>>()
function renderBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
const hit = bases.get(folded)
if (hit && hit.outbox === outbox) return hit
const base = buildBase(folded, outbox)
bases.set(folded, base)
return base
}
function buildBase(folded: SessionFoldState, outbox: EngineState["outbox"]) {
const pending =
outbox.length === 0
? folded.inbox
: [
...folded.inbox,
...outbox.map(
(intent): SessionInboxInfo => ({
id: intent.id,
sessionID: folded.session.id,
timeCreated: intent.created,
...intent.item,
}),
),
]
const appended = pendingMessages(folded, pending)
return {
outbox,
pending,
messages: appended.length === 0 ? folded.messages : [...folded.messages, ...appended],
}
}
function pendingMessages(folded: SessionFoldState, pending: ReadonlyArray<SessionInboxInfo>) {
if (pending.length === 0) return []
const messageIDs = new Set(folded.messages.map((message) => message.id))
return 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] : []
})
}
const usageSessions = new WeakMap<
Extract<OverlayEntry, { type: "usage" }>,
{ base: SessionFoldState["session"]; session: SessionFoldState["session"] }
>()
function usageSession(folded: SessionFoldState, entry: OverlayEntry | undefined) {
if (entry?.type !== "usage") return folded.session
const hit = usageSessions.get(entry)
if (hit && hit.base === folded.session) return hit.session
const session = { ...folded.session, cost: entry.value.cost, tokens: entry.value.tokens }
usageSessions.set(entry, { base: folded.session, session })
return session
}
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) {
if (overlay.size === 0) return messages
// Remap only the messages the overlay actually touches so everything else
// keeps its identity.
const compacting = overlay.has("compaction")
const touched = new Set<string>()
overlay.forEach((_, key) => {
const id = keyMessageID(key)
if (id) touched.add(id)
})
if (touched.size === 0 && !compacting) return messages
return messages.map((message): SessionMessageInfo => {
if (message.type === "compaction" && message.status === "running") {
if (!compacting) return message
const entry = overlay.get("compaction")
return entry?.type === "compaction" ? { ...message, summary: message.summary + entry.value } : message
}
if (message.type !== "assistant" || !touched.has(message.id)) 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}`
}
// Second segment of a part or tool key; undefined for the segmentless
// "compaction" and "usage" keys.
function keyMessageID(key: string) {
return key.split(":")[1]
}
export * as Engine from "./engine"
+580
View File
@@ -0,0 +1,580 @@
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,2 +1,3 @@
export * from "./data"
export * from "./connection"
export * from "./engine-data"
+145
View File
@@ -0,0 +1,145 @@
// Proves the generated-client wiring the engine laws take for granted: the
// adapter in src/solid/engine-data.ts must speak the real snapshot/log/prompt
// API shapes and translate the generated typed errors into the engine's own
// (the SeqUnavailable path is what laws 7-9 in test/sync-engine-laws.test.ts
// rely on in production).
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { Engine } from "../src/solid/engine/engine"
import { SNAPSHOT_RECENT, createEngineData, 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("ses_transport")
const calls: Array<unknown> = []
const transport = createEngineTransport(() => ({
async snapshot(input) {
calls.push(input)
// The generated client returns mutable arrays; the fixture's snapshot
// is readonly, so mirror the wire shape here.
const value = server.snapshotValue()
return { ...value, children: [...value.children], inbox: [...value.inbox], messages: [...value.messages] }
},
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: Array<Engine.SessionStreamItem> = []
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: SNAPSHOT_RECENT },
{ 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: "ses_submit",
request: {
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
})
expect(requests).toEqual([
{
id: "msg_client",
sessionID: "ses_submit",
text: "hello",
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
delivery: "queue",
},
])
})
test("a failed initial attach does not poison the session cache", async () => {
const server = new FakeSessionServer("ses_attach_retry")
server.faults.loseSnapshots = 1
const api = {
session: {
snapshot: (input: { sessionID: string }) => server.snapshot(input.sessionID),
log: (input: { sessionID: string; after: number }) => server.stream(input.sessionID, input.after),
prompt: () => Promise.reject(new Error("unused")),
},
}
await createRoot(async (dispose) => {
const data = createEngineData({
api: () => api as never,
directory: "/workspace",
event: { on: () => () => {}, listen: () => () => {} },
})
// The server is down when the session first opens…
await expect(data.session.sync(server.sessionID)).rejects.toThrow("snapshot lost")
// …and the next sync attaches with a fresh engine instead of a cached rejection.
await data.session.sync(server.sessionID)
expect(data.session.get(server.sessionID)?.id).toBe(server.sessionID)
dispose()
})
})
test("translates generated typed failures", async () => {
// These literals mirror the generated client's error DTO shapes
// (SeqUnavailableError / InvalidRequestError in src/promise/generated);
// they must change if the generated error schema does.
const transport = createEngineTransport(() => ({
async snapshot() {
throw new Error("unused")
},
async *log() {
throw { _tag: "SeqUnavailableError", sessionID: "ses_errors", after: 2, head: 1, message: "gone" }
},
async prompt() {
throw { _tag: "InvalidRequestError", message: "invalid" }
},
}))
const streamError = await collectError(transport.stream("ses_errors", 2))
expect(streamError).toBeInstanceOf(Engine.SeqUnavailable)
await expect(
transport.submit({ id: "msg_client", sessionID: "ses_errors", 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")
}
+2
View File
@@ -11,6 +11,8 @@ if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
}
if (mode === "environment")
await writeFile(registration + ".environment", process.env.OPENCODE_SERVICE_ENV_TEST ?? "")
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
+231
View File
@@ -0,0 +1,231 @@
// In-memory model of the server's session log, used by the engine laws
// (sync-engine-laws.test.ts), the chaos simulation (sync-engine-sim.test.ts),
// and the legacy bug catalog (legacy-divergence.test.ts). It folds with the
// REAL SessionFold, so `truth()` is the same interpretation of events a
// converged client must reach, and its admission dedupes by inbox ID exactly
// like the server's inbox projector. Faults are injected per call through the
// `faults` record; `cutConnections` and `prune` model disconnects and lost
// retention.
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,
loseSnapshots: 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, time))
}
async snapshot(sessionID: string) {
await this.pause()
this.assertSession(sessionID)
if (this.faults.loseSnapshots > 0) {
this.faults.loseSnapshots--
throw new Error("snapshot lost")
}
return this.snapshotValue()
}
async *stream(sessionID: string, after: number, signal?: AbortSignal): 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>()
const abort = () => queue.fail(new Error("stream aborted"))
signal?.addEventListener("abort", abort, { once: true })
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 {
signal?.removeEventListener("abort", abort)
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
}
/** Clear every injected fault. */
heal() {
for (const fault of Object.keys(this.faults) as Array<keyof FakeSessionServer["faults"]>) this.faults[fault] = 0
}
seq() {
return this.folded.seq
}
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()
}
}
/**
* Reconnect option that holds the engine's first reconnect until released,
* so a test can advance the server "while disconnected". Later reconnects
* pass through instantly.
*/
export function reconnectGate() {
let open = false
let release: (() => void) | undefined
return {
reconnect: () =>
new Promise<void>((resolve) => {
if (open) return resolve()
release = () => {
open = true
resolve()
}
}),
holding: () => release !== undefined,
release: () => release!(),
}
}
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, time: number): SessionSnapshot {
const session: SessionInfo = {
id: sessionID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: time, updated: time },
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() {
if (this.values.length) return Promise.resolve(this.values.shift()!)
if (this.error) return Promise.reject(this.error)
return new Promise<Value>((resolve, reject) => this.waiting.push({ resolve, reject }))
}
}
@@ -0,0 +1,258 @@
// Divergence catalog: weird states the legacy data layer (createData) can get
// into that the sync engine cannot. Each test drives the REAL legacy layer —
// or, for the retry test, its raw ID-less prompt protocol — and PASSES by
// demonstrating the bug, with a pointer to the engine law or mechanism that
// rules the same state out. If a test here starts failing, the legacy layer
// got fixed — celebrate and delete the test.
//
// Companion clean-behavior proofs: test/sync-engine-laws.test.ts.
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData } from "../src/solid/data"
import type { CreateDataInput } from "../src/solid/data"
import type { OpenCodeEvent, SessionMessageInfo } from "../src/promise"
import { FakeSessionServer } from "./fixture/sync-engine"
const sessionID = "ses_legacy"
const assistantID = "msg_assistant"
describe("legacy data layer divergence catalog", () => {
test("a dropped durable event desyncs the transcript silently and forever", async () => {
// Server truth: the assistant message finished with text "FINAL".
// The client misses only the `session.text.ended` event (blip mid-stream).
const legacy = await hydrated()
legacy.dispatch(textStarted())
for (let index = 0; index < 5; index++) legacy.dispatch(textDelta("x"))
// ...the `ended` event with the durable final text never arrives.
// The transcript is stuck on accumulated deltas, disagreeing with the
// server, and nothing in the layer can ever notice: there is no sequence
// cursor, no gap check, no recovery path. Only a manual refetch heals it.
expect(legacy.text()).toBe("xxxxx")
legacy.dispose()
// Engine: durable events carry seqs; a gap surfaces as SeqUnavailable or a
// marker past the fold, forcing snapshot recovery (laws 7 and 8).
})
test("a late delta corrupts a completed message", async () => {
// Events delivered slightly out of order: the final text lands, then a
// straggling delta from the finished stream arrives.
const legacy = await hydrated()
legacy.dispatch(textStarted())
legacy.dispatch(textDelta("Hel"))
legacy.dispatch(textEnded("Hello"))
legacy.dispatch(textDelta("lo"))
// The handler appends onto whatever text part it finds — including a
// completed one. The final message is permanently corrupted.
expect(legacy.text()).toBe("Hellolo")
legacy.dispose()
// Engine: deltas are ephemeral overlay entries cleared by the durable
// lifecycle events, and the ordered log cannot deliver a delta after its
// own `ended` — there is no durable state for a straggler to corrupt.
})
test("a slow fetch rewinds the store past already-rendered live events", async () => {
// The initial message fetch is in flight when a live prompt admission
// arrives. The user's message renders... then the stale fetch resolves.
let resolveFetch: ((messages: SessionMessageInfo[]) => void) | undefined
const legacy = makeLegacy({
list: () => new Promise<SessionMessageInfo[]>((resolve) => (resolveFetch = resolve)),
})
const syncing = legacy.data.session.message.sync(sessionID)
legacy.dispatch(inboxEnqueued("msg_user"))
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeDefined()
resolveFetch!([]) // the fetch was served before the admission — stale
await syncing
// The message the user just watched appear is gone. It returns only if
// some later event or refetch happens to bring it back.
expect(legacy.data.session.message.get(sessionID, "msg_user")).toBeUndefined()
legacy.dispose()
// Engine: hydration is a seq-stamped snapshot, and a stale refresh cannot
// move the fold behind the live log (law 10, refresh monotonicity).
})
test("delivered-before-enqueued leaves a phantom pending row forever", async () => {
// Reordered delivery: the `delivered` event arrives before its `enqueued`.
const legacy = await hydrated()
legacy.dispatch(inboxDelivered("msg_user")) // no-op: nothing to deliver yet
legacy.dispatch(inboxEnqueued("msg_user")) // adds the pending row
// The delivered event was already consumed, so the row the server has
// long since promoted sits in "pending" until a manual refetch.
expect(legacy.data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_user"])
legacy.dispose()
// Engine: the transport is a single ordered log, so this ordering cannot
// be observed live; a reconnect replays from the seq cursor, and any gap
// fails the cursor check and recovers via snapshot (laws 7 and 8).
})
test("a retry after a lost response admits the prompt twice", async () => {
// Both protocols drive the same server admission logic (FakeSessionServer
// dedupes by inbox ID exactly like the real projector). The only
// difference is who mints the ID.
// Legacy protocol: the request carries no ID, so the server mints a fresh
// one per attempt and cannot recognize a retry. The response to the first
// send is lost, the user presses enter again — the transcript now has the
// prompt twice.
const legacyServer = new FakeSessionServer(sessionID)
legacyServer.faults.loseResponses = 1
let minted = 0
const legacySend = (text: string) =>
legacyServer.submit({ id: `msg_minted_${++minted}`, sessionID, request: { text } })
await legacySend("hello").catch(() => {})
await legacySend("hello")
expect(legacyServer.admitted).toHaveLength(2)
// Engine protocol: the retry reuses the client-minted ID and the same
// server admits exactly once. (Law 1 proves this end-to-end through the
// real engine retry loop; this is the raw protocol contrast.)
const engineServer = new FakeSessionServer(sessionID)
engineServer.faults.loseResponses = 1
const engineSend = () => engineServer.submit({ id: "msg_client", sessionID, request: { text: "hello" } })
await engineSend().catch(() => {})
await engineSend()
expect(engineServer.admitted).toEqual(["msg_client"])
})
test("a dropped execution event leaves an interrupted session spinning forever", async () => {
// The user hits interrupt; the server stops the run; the terminal
// `session.execution.interrupted` event is lost in a reconnect blip.
const legacy = await hydrated()
legacy.dispatch(executionStarted())
// Status only ever changes on the terminal event (lost) or a full
// reconnect's active-session refetch — until one of those happens the
// spinner spins over a session the server already stopped.
expect(legacy.data.session.status(sessionID)).toBe("running")
legacy.dispose()
// Engine: activity is folded durable state behind the seq cursor, so the
// gap itself is detected and snapshot recovery resyncs activity with the
// server (laws 7 and 8 pin the mechanism).
})
})
// Also part of the catalog, straight from the legacy source: the layer
// documents its own event-vs-fetch race — see the session.created "band-aid"
// comment in src/solid/data.ts (skipping racy initial reads so live events
// are not overwritten by stale fetches).
function makeLegacy(overrides: { list?: () => Promise<SessionMessageInfo[]> } = {}) {
let handler: ((event: { name: OpenCodeEvent["type"]; details: OpenCodeEvent }) => void) | undefined
const api = {
session: {
get: async () => ({
id: sessionID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
location: { directory: "/workspace" },
}),
},
message: {
list: async () => ({
data: overrides.list ? await overrides.list() : transcript().toReversed(),
cursor: {},
}),
},
} as unknown as ReturnType<CreateDataInput["api"]>
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/workspace",
event: {
on: () => () => {},
listen(next) {
handler = next
return () => {}
},
},
})
return {
data,
dispose,
dispatch(event: { type: OpenCodeEvent["type"] } & Record<string, unknown>) {
handler?.({ name: event.type, details: event as unknown as OpenCodeEvent })
},
text() {
const message = data.session.message.get(sessionID, assistantID)
const part =
message?.type === "assistant" ? message.content.findLast((item) => item.type === "text") : undefined
return part?.type === "text" ? part.text : undefined
},
}
})
}
async function hydrated() {
const legacy = makeLegacy()
await legacy.data.session.sync(sessionID)
await legacy.data.session.message.sync(sessionID)
return legacy
}
function transcript(): SessionMessageInfo[] {
return [
{ id: "msg_earlier", type: "user", text: "earlier", time: { created: 1 } },
{
id: assistantID,
type: "assistant",
time: { created: 2 },
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
},
]
}
const textStarted = () => ({
id: "evt_start",
created: 3,
type: "session.text.started" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0 },
})
let deltaCount = 0
const textDelta = (delta: string) => ({
id: `evt_delta_${++deltaCount}`,
created: 4,
type: "session.text.delta" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta },
})
const textEnded = (text: string) => ({
id: "evt_end",
created: 5,
type: "session.text.ended" as const,
data: { sessionID, assistantMessageID: assistantID, ordinal: 0, text },
})
const inboxEnqueued = (inboxID: string) => ({
id: "evt_enqueued",
created: 6,
type: "session.inbox.enqueued" as const,
data: {
sessionID,
inboxID,
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
})
const inboxDelivered = (inboxID: string) => ({
id: "evt_delivered",
created: 7,
type: "session.inbox.delivered" as const,
data: { sessionID, inboxID },
})
const executionStarted = () => ({
id: "evt_execution",
created: 8,
type: "session.execution.started" as const,
data: { sessionID },
})
@@ -59,6 +59,26 @@ test("ensures a missing service with native promises", async () => {
}
})
test("adds configured environment variables with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("waits for a live contender when another native contender fails", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+22
View File
@@ -68,6 +68,28 @@ test("reuses a compatible registered service", async () => {
expect(existing.exitCode).toBe(null)
})
test("adds configured environment variables when starting a service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "environment"],
env: { OPENCODE_SERVICE_ENV_TEST: "configured" },
}),
)
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(await Bun.file(registration + ".environment").text()).toBe("configured")
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
@@ -0,0 +1,201 @@
// Laws of the session sync engine: each test pins one property the engine
// must hold under transport faults. Cited by number from
// test/legacy-divergence.test.ts (the legacy bug catalog these laws rule out)
// and stress-tested together by test/sync-engine-sim.test.ts. The server
// model lives in test/fixture/sync-engine.ts and folds with the real
// SessionFold, so `server.truth()` is the state a converged client must show.
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { Engine } from "../src/solid/engine/engine"
import { FakeSessionServer, reconnectGate, 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("ses_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)
// The admit landed but the response was lost; the reconnect makes the
// engine resend the same client-minted ID — that resend is what
// idempotency must absorb.
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("ses_echo")
const engine = await Engine.createSessionEngine(server.sessionID, server, { now: () => server.time })
await engine.ready()
// The "echo" is the server's inbox.enqueued event for our own submit:
// folding it over the optimistic render must be invisible — no flicker.
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.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "")
expect(code).not.toMatch(/\boutbox\b/)
expect(code).not.toContain("./engine")
expect(code).not.toMatch(/\bintents?\b/i)
})
test("4. ordering: a burst admits in submission order", async () => {
const server = new FakeSessionServer("ses_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("ses_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.seq() && b.view().seq === server.seq())
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("ses_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("ses_lossy")
const gate = reconnectGate()
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: gate.reconnect,
})
engine.submit({ id: "msg_1", text: "first" })
await engine.settled()
server.cutConnections()
await until(gate.holding)
// 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()
gate.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("ses_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("9. outage recovery: failed recovery snapshots retry until the server returns", async () => {
const server = new FakeSessionServer("ses_outage")
const gate = reconnectGate()
const engine = await Engine.createSessionEngine(server.sessionID, server, {
now: () => server.time,
reconnect: gate.reconnect,
})
engine.submit({ id: "msg_1", text: "first" })
await engine.settled()
server.cutConnections()
await until(gate.holding)
// A server restart while disconnected: history is gone, and the server
// stays unreachable for the first snapshot attempts of the recovery.
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
server.prune()
server.faults.loseSnapshots = 3
gate.release()
await until(() => engine.view().seq === 2)
expect(server.faults.loseSnapshots).toBe(0)
expect(engine.view()).toEqual(server.truth())
engine.stop()
})
test("10. refresh monotonicity: a stale snapshot refresh cannot move the fold behind the live log", async () => {
const server = new FakeSessionServer("ses_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)
// ...and the un-echoed intent survives the rejected refresh.
expect(engine.view().pending.map((item) => item.id)).toEqual(["msg_1"])
engine.stop()
})
})
@@ -0,0 +1,127 @@
// Seeded chaos simulation: two engine clients share one FakeSessionServer
// while every fault the fixture can inject is thrown at them at random, then
// all faults heal and both clients must converge exactly to the server's
// truth. This stress-tests the laws of test/sync-engine-laws.test.ts in
// combination; failures reproduce deterministically from the seed.
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(`ses_sim_${seed}`)
const clients = await Promise.all([makeClient("a", server), makeClient("b", server)])
// Chaos phase. Per step: 45% submit from a random client, 10% cut all
// connections, 10% lose a response, 8% lose a burst of requests,
// 7% reject an admission, 7% lose a snapshot fetch, 13% shift latency.
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 if (roll < 0.87) {
server.faults.loseSnapshots++
} else {
server.faults.latency = Math.floor(random() * 6)
}
await advance(2 + Math.floor(random() * 8))
}
// Drain phase: heal all faults, then repeatedly cut connections —
// reconnecting is what makes the engine resend intents whose responses
// were lost, so every submitted ID ends up admitted or rejected.
server.heal()
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.seq()),
`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
}
// Once an admitted message first renders, it appears exactly once in every
// subsequent view — it never disappears or duplicates.
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)
}
}
}
// One "step" is one microtask turn — each fixture `pause()` under
// `faults.latency` consumes one — followed by a macrotask flush.
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
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"include": [
"src",
"script",
"test/fixture",
"test/engine-data.test.ts",
"test/legacy-divergence.test.ts",
"test/sync-engine-laws.test.ts",
"test/sync-engine-sim.test.ts"
]
}
+1 -1
View File
@@ -109,7 +109,7 @@ const layer = Layer.effect(
get: Effect.fn("Agent.get")(function* (id) {
return state.get().agents.get(id)
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
resolve: Effect.fnUntraced(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
}),
+27 -1
View File
@@ -44,6 +44,21 @@ 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
@@ -150,6 +165,7 @@ 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>
@@ -768,6 +784,7 @@ 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* () {
@@ -791,7 +808,8 @@ 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 wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
const target = yield* latestSequence(db, input.aggregateID)
const marker: EventLog.Synced = {
type: "log.synced",
@@ -802,6 +820,14 @@ 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 -1
View File
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
entries: Effect.fnUntraced(function* () {
return configs
}),
update,
+43 -28
View File
@@ -1,54 +1,69 @@
export * as ConfigFormatterPlugin from "./formatter.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
export const Plugin = define({
id: "opencode.config.formatter",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const formatter = yield* Formatter.Service
const loaded = { entries: [] as Entry[] }
yield* ctx.event
.subscribe()
.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
if (configured === false) {
draft.clear()
return
}
if (configured === undefined) {
for (const item of draft.list()) if (item.builtIn) draft.remove(item.name)
return
}
if (!configured) return
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
builtIns.forEach(draft.set)
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
if (entry.disabled) {
draft.remove(name)
continue
}
const current = draft.get(name)
draft.set(name, {
const builtIn = builtIns.find((formatter) => formatter.name === name)
const current: Info = {
name,
extensions: entry.extensions ?? current?.extensions ?? [],
environment: { ...current?.environment, ...entry.environment },
extensions: entry.extensions ?? builtIn?.extensions ?? [],
environment: { ...builtIn?.environment, ...entry.environment },
enabled:
current && !entry.command ? current.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
})
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
draft.set(current)
}
})
}),
+20 -22
View File
@@ -1,7 +1,6 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
@@ -11,30 +10,29 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const image = yield* Image.Service
const loaded = { entries: [] as Entry[] }
yield* ctx.event
.subscribe()
.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(image.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(image.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
yield* image.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.media?.image) continue
draft.update((policy) => {
const configured = entry.info.media?.image
if (!configured) return
if (configured.auto_resize !== undefined) policy.autoResize = configured.auto_resize
if (configured.max_width !== undefined) policy.maxWidth = configured.max_width
if (configured.max_height !== undefined) policy.maxHeight = configured.max_height
if (configured.max_base64_bytes !== undefined) policy.maxBase64Bytes = configured.max_base64_bytes
if (entry.type !== "document") continue
const configured = entry.info.media?.image
if (!configured) continue
draft.configure({
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
...(configured.max_base64_bytes === undefined ? {} : { maxBase64Bytes: configured.max_base64_bytes }),
})
}
})
@@ -1,37 +0,0 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = { entries: [] as Entry[] }
yield* ctx.event
.subscribe()
.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
draft.update((policy) => {
if (configured.max_lines !== undefined) policy.maxLines = configured.max_lines
if (configured.max_bytes !== undefined) policy.maxBytes = configured.max_bytes
})
})
}),
})
@@ -120,9 +120,13 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
if (method === "values") return statement.values.pipe(Effect.withTracerEnabled(false))
if (method === "get")
return statement.withoutTransform.pipe(
Effect.map((rows) => rows[0]),
Effect.withTracerEnabled(false),
)
return statement.withoutTransform.pipe(Effect.withTracerEnabled(false))
}
private isInTransaction() {
+19 -37
View File
@@ -4,24 +4,18 @@ import { Context, Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { make, type Info } from "./formatter/builtins.js"
import type { Info } from "./formatter/builtins.js"
import { State } from "./state.js"
type Data = {
readonly formatters: Map<string, Info>
formatters: Info[]
}
export interface Draft {
readonly list: () => readonly Info[]
readonly get: (name: string) => Info | undefined
readonly set: (name: string, formatter: Info) => void
readonly remove: (name: string) => void
readonly clear: () => void
export type Draft = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
@@ -33,33 +27,21 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const global = yield* Global.Service
const commands = new WeakMap<Info, string[] | false>()
const builtIns = make({
directory: location.directory,
worktree: location.project.directory,
fs,
npm,
processes,
bin: global.bin,
})
const state = State.create<Data, Draft>({
name: "formatter",
initial: () => ({
formatters: new Map(builtIns.map((formatter) => [formatter.name, { ...formatter, builtIn: true }])),
}),
draft: (data) => ({
list: () => Array.from(data.formatters.values()),
get: (name) => data.formatters.get(name),
set: (name, formatter) => data.formatters.set(name, { ...formatter, name }),
remove: (name) => {
data.formatters.delete(name)
initial: () => ({ formatters: [] }),
draft: (draft) => ({
set: (formatter) => {
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) draft.formatters.push(formatter)
else draft.formatters[index] = formatter
},
remove: (name) => {
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
},
clear: () => data.formatters.clear(),
}),
})
@@ -72,9 +54,9 @@ const layer = Layer.effect(
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
const matching = Array.from(state.get().formatters.values()).filter((formatter) =>
formatter.extensions.includes(path.extname(filepath)),
)
const matching = state
.get()
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
for (const formatter of matching) {
const enabled = yield* command(formatter)
@@ -112,12 +94,12 @@ const layer = Layer.effect(
return false
})
return Service.of({ file, transform: state.transform, reload: state.reload })
return Service.of({ transform: state.transform, reload: state.reload, file })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
deps: [Location.node, AppProcess.node],
})
-1
View File
@@ -7,7 +7,6 @@ import { which } from "../util/which.js"
export interface Info {
readonly name: string
readonly builtIn?: boolean
readonly environment?: Record<string, string>
readonly extensions: readonly string[]
readonly enabled: Effect.Effect<string[] | false>
+17 -19
View File
@@ -3,7 +3,6 @@ export * as Image from "./image.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { FileSystem } from "./filesystem.js"
import type { DeepMutable } from "./schema.js"
import { State } from "./state.js"
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
@@ -33,15 +32,15 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
}
}
export interface Policy {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
export type Limits = {
autoResize: boolean
maxWidth: number
maxHeight: number
maxBase64Bytes: number
}
export interface Draft {
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
export type Draft = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
@@ -59,7 +58,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<DeepMutable<Policy>, Draft>({
const state = State.create<Limits, Draft>({
name: "image",
initial: () => ({
autoResize: true,
@@ -67,8 +66,13 @@ const layer = Layer.effect(
maxHeight: 2_000,
maxBase64Bytes: 5 * 1024 * 1024,
}),
draft: (policy) => ({
update: (update) => update(policy),
draft: (draft) => ({
configure: (limits) => {
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
},
}),
})
const loadAdapter = yield* Effect.cached(
@@ -81,16 +85,10 @@ const layer = Layer.effect(
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
) {
const policy = state.get()
const normalize = yield* loadAdapter
return yield* normalize(resource, content, {
autoResize: policy.autoResize,
maxWidth: policy.maxWidth,
maxHeight: policy.maxHeight,
maxBase64Bytes: policy.maxBase64Bytes,
})
return yield* normalize(resource, content, state.get())
})
return Service.of({ normalize, transform: state.transform, reload: state.reload })
return Service.of({ transform: state.transform, reload: state.reload, normalize })
}),
)
+5 -5
View File
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
@@ -170,7 +170,7 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fn("Job.fork")(function* (
const fork = Effect.fnUntraced(function* (
scope: Scope.Scope,
id: string,
token: object,
@@ -192,7 +192,7 @@ export const make = Effect.gen(function* () {
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
const start: Interface["start"] = Effect.fnUntraced(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
@@ -247,7 +247,7 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true }
})
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
@@ -258,7 +258,7 @@ export const make = Effect.gen(function* () {
})
})
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const block: Interface["block"] = Effect.fnUntraced(function* (input) {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
+1 -1
View File
@@ -65,7 +65,7 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
return {
+2 -2
View File
@@ -152,14 +152,14 @@ const layer = Layer.effect(
)
})
const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
+1 -1
View File
@@ -39,7 +39,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const list = Effect.fnUntraced(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
+47 -15
View File
@@ -1,15 +1,14 @@
export * as Plugin from "./plugin.js"
export { Event, ID, Info } from "@opencode-ai/schema/plugin"
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Formatter } from "./formatter.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { MCP } from "./mcp/index.js"
@@ -24,11 +23,17 @@ import { Tool } from "./tool.js"
import { PluginHooks } from "./plugin/hooks.js"
export interface Interface {
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
}
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
readonly version: string
readonly source?: Plugin.Source
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
@@ -39,6 +44,7 @@ const layer = Layer.effect(
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
@@ -57,15 +63,18 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (Exit.isSuccess(loaded)) return child
if (Exit.isSuccess(loaded)) return { scope: child } as const
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": plugin.id,
cause: loaded.cause,
})
return undefined
return { error: Cause.pretty(loaded.cause) } as const
})
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
const ids = new Set<Plugin.ID>()
for (const definition of definitions) {
@@ -86,26 +95,40 @@ const layer = Layer.effect(
const candidate = next[index]
return definition.id === candidate?.id && definition.version === candidate.version
})
)
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
const loaded = yield* load(definition)
if (loaded) {
active.set(definition.id, { plugin: definition, scope: loaded })
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
status: "failed",
error: loaded.error,
tui: definition.tui ?? false,
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored) {
active.set(definition.id, { plugin: previous.plugin, scope: restored })
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -120,6 +143,7 @@ const layer = Layer.effect(
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -137,7 +161,7 @@ const layer = Layer.effect(
const service = Service.of({
activate,
list: Effect.fn("Plugin.list")(function* () {
return Array.from(active.keys()).map((id) => ({ id }))
return inventory
}),
})
host = yield* PluginHost.make(service)
@@ -145,6 +169,15 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
status: "active",
tui: plugin.tui ?? false,
}
}
export const node = makeLocationNode({
service: Service,
layer,
@@ -155,7 +188,6 @@ export const node = makeLocationNode({
AISDK.node,
Catalog.node,
Command.node,
Formatter.node,
Integration.node,
MCP.node,
Location.node,
+44 -22
View File
@@ -4,6 +4,7 @@ import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state.js"
@@ -26,16 +27,26 @@ interface Failures extends Record<keyof Domains, unknown> {
}
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
type Entry = { readonly callback: Function; readonly options?: ModelHookOptions }
const eventProviderID = (event: unknown) => {
if (typeof event !== "object" || event === null || !("model" in event)) return undefined
const model = event.model
if (typeof model !== "object" || model === null || !("providerID" in model)) return undefined
return typeof model.providerID === "string" ? model.providerID : undefined
}
export interface Interface {
readonly has: <Domain extends keyof Domains>(
domain: Domain,
name: keyof Domains[Domain] & keyof Failures[Domain],
providerID?: string,
) => Effect.Effect<boolean>
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
options?: ModelHookOptions,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
@@ -49,36 +60,47 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Function[]>()
const callbacks = new Map<string, Entry[]>()
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const register: Interface["register"] = Effect.fn("PluginHooks.register")(
function* (domain, name, callback, options) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
const entry = { callback, options }
callbacks.set(id, [...(callbacks.get(id) ?? []), entry])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== entry)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
},
)
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
event,
])
const trigger: Interface["trigger"] = Effect.fnUntraced(function* (domain, name, event) {
for (const entry of callbacks.get(key(domain, name)) ?? []) {
if (entry.options?.providerID !== undefined && entry.options.providerID !== eventProviderID(event)) continue
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(
entry.callback,
undefined,
[event],
)
yield* result
}
return event
})
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
const has: Interface["has"] = (domain, name, providerID) =>
Effect.sync(() =>
(callbacks.get(key(domain, name)) ?? []).some(
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
),
)
return Service.of({ has, register, trigger })
}),
+4 -20
View File
@@ -12,7 +12,6 @@ import { AISDK } from "../aisdk.js"
import { Catalog } from "../catalog.js"
import { Command } from "../command.js"
import { Credential } from "../credential.js"
import { Formatter } from "../formatter.js"
import { Bus } from "../bus.js"
import { Integration } from "../integration.js"
import { Location } from "../location.js"
@@ -37,7 +36,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
const commands = yield* Command.Service
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const formatter = yield* Formatter.Service
const mcp = yield* MCP.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
@@ -106,9 +104,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}),
},
aisdk: {
hook: (name, callback) => {
hook: (name, callback, options) => {
if (name === "sdk") {
return aisdk.hook.sdk((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = {
model: mutable(event.model),
package: event.package,
@@ -121,6 +120,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}
return aisdk.hook.language((event) => {
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
const output = {
model: mutable(event.model),
options: event.options,
@@ -187,22 +187,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
event: {
subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)),
},
formatter: {
reload: formatter.reload,
transform: (callback) =>
formatter.transform((draft) => {
callback({
add: (definition) =>
draft.set(definition.name, {
name: definition.name,
extensions: [...definition.extensions],
environment: definition.environment === undefined ? undefined : { ...definition.environment },
enabled: Effect.succeed([...definition.command]),
}),
remove: draft.remove,
})
}),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
@@ -400,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}),
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
hook: (name, callback, options) => hooks.register("session", name, callback, options),
create: (input) =>
runtime.session.create({
id: input?.id,
+4 -6
View File
@@ -3,6 +3,7 @@ export * as PluginInternal from "./internal.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppProcess } from "@opencode-ai/util/process"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Agent } from "../agent.js"
@@ -22,7 +23,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileMutation } from "../file-mutation.js"
@@ -60,7 +60,6 @@ import { ShellTool } from "../tool/plugin/shell.js"
import { SkillTool } from "../tool/plugin/skill.js"
import { SubagentTool } from "../tool/plugin/subagent.js"
import { Tool } from "../tool.js"
import { ToolOutput } from "../tool-output.js"
import { WebFetchTool } from "../tool/plugin/webfetch.js"
import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
@@ -81,6 +80,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
@@ -115,11 +115,11 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
@@ -154,7 +154,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
@@ -166,6 +165,7 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
export const requirements = LayerNode.group([
Agent.node,
AppProcess.node,
Catalog.node,
Command.node,
Config.node,
@@ -200,7 +200,6 @@ export const requirements = LayerNode.group([
Skill.node,
SkillDiscovery.node,
Tool.node,
ToolOutput.node,
Watcher.node,
WellKnown.node,
])
@@ -241,7 +240,6 @@ const post = [
ConfigCommandPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
ConfigImagePlugin.Plugin,
ConfigToolOutputPlugin.Plugin,
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
@@ -241,19 +241,22 @@ export const GithubCopilotPlugin = define({
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
yield* ctx.session.hook("http.request", (evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
}),
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.aisdk.hook(
"language",
+11 -10
View File
@@ -5,7 +5,6 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -230,15 +229,17 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
}),
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.sync(() => {
if (!chatgpt) return
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
evt.baseURL = codexBaseURL
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
{ providerID: Provider.ID.openai },
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
+1 -1
View File
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
@@ -0,0 +1,14 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+47 -20
View File
@@ -1,8 +1,9 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -14,17 +15,20 @@ import { PluginPromise } from "../plugin/promise.js"
import { PluginInternal } from "./internal.js"
import { SdkPlugins } from "./sdk.js"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -42,10 +46,12 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const failures = new Map<string, Extract<Plugin.Info, { readonly status: "failed" }>>()
const plugins = () => [...definitions, ...packages.values()]
for (const operation of operations) {
if (operation.type === "remove") {
if (operation.target === "*") failures.clear()
plugins()
.filter((plugin) => matches(operation.target, plugin.id))
.forEach((plugin) => enabled.delete(plugin.id))
@@ -65,21 +71,35 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const plugin = yield* load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
)
if (!plugin) continue
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
status: "failed",
error: plugin.error,
tui: false,
})
continue
}
failures.delete(operation.target)
const previous = packages.get(operation.target)
if (previous) enabled.delete(previous.id)
packages.set(operation.target, plugin)
enabled.add(plugin.id)
}
return [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...Array.from(packages.values()).filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
@@ -89,7 +109,7 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
if (!entrypoint) return
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
@@ -103,18 +123,13 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -129,13 +144,20 @@ export const layer = Layer.effect(
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
// Combine internal plugins with host-contributed SDK plugins in boot order.
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const plugins = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(plugins)
yield* registry.activate(resolved.plugins, resolved.failures)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
@@ -172,4 +194,9 @@ const nodeDeps = [
PluginInternal.requirements,
] as const
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
+118 -16
View File
@@ -40,6 +40,7 @@ import { SessionRevert } from "./session/revert.js"
import { Session } from "@opencode-ai/schema/session"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "./image.js"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import { Mime } from "./mime.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Event } from "@opencode-ai/schema/event"
@@ -140,6 +141,11 @@ export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
export class SeqUnavailableError extends Schema.TaggedError<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.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
@@ -193,13 +199,33 @@ 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>
/**
* 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.
* 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.
* The marker's seq may exceed the last emitted event because other durable
* bus share the aggregate's sequence space.
*/
@@ -207,7 +233,8 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
follow?: boolean
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
ephemeral?: boolean
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
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>
@@ -325,6 +352,7 @@ 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(
@@ -556,20 +584,90 @@ 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)),
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),
),
),
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)),
prompt: Effect.fn("Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
@@ -579,7 +677,11 @@ const layer = Layer.effect(
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
// Resolved lazily so prompt admission only boots location services when an
// image attachment actually needs the resizer.
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
const image = Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Image.Service
}).pipe(Effect.provide(locations.get(session.location)))
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
const prompt = yield* resolvePrompt(
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
+18 -15
View File
@@ -12,6 +12,7 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionMessage } from "./message.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { App } from "../app.js"
@@ -270,23 +271,25 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
LLM.request({
model: plan.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)],
tools: [],
}),
)
yield* dependencies.llm
.stream(
LLM.request({
model: plan.model,
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
messages: [Message.user(plan.prompt)],
tools: [],
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: plan.session.id,
agent: Agent.ID.make("compaction"),
model: plan.ref,
}),
},
)
})
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
+11 -8
View File
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -71,7 +72,9 @@ export const layer = Layer.effect(
providerID: model.ref.providerID,
modelID: model.ref.id,
})
const response = yield* llm.generate(
const request = yield* SessionModelHook.apply(
hooks,
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
@@ -80,14 +83,14 @@ export const layer = Layer.effect(
messages: contextEvent.messages,
tools: hookedTools,
}),
{
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
},
)
const response = yield* llm.generate(request, {
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
})
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
}),
+21 -7
View File
@@ -180,13 +180,27 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return yield* serialized(
input.sessionID,
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
if (pending) return pending
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}),
)
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
+34
View File
@@ -0,0 +1,34 @@
export * as SessionModelHook from "./model-hook.js"
import { HttpOptions, LanguageModel, LLMRequest } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { PluginHooks } from "../plugin/hooks.js"
export const apply = (
hooks: PluginHooks.Interface,
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
request: LLMRequest,
) =>
Effect.gen(function* () {
const currentBaseURL = request.model.route.endpoint.baseURL
const event = yield* hooks.trigger("session", "model.request", {
...input,
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
headers: { ...request.http?.headers },
})
const route =
event.baseURL !== undefined && event.baseURL !== currentBaseURL
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
: request.model.route
return LLMRequest.update(request, {
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
http: new HttpOptions({
body: request.http?.body,
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
query: request.http?.query,
}),
})
})
+20 -14
View File
@@ -14,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
@@ -226,20 +227,25 @@ export const layer = Layer.effect(
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
}),
)
const request = LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
})
const request = yield* SessionModelHook.apply(
hooks,
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({
model,
http: {
headers: SessionModelHeaders.make(session, app),
},
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
system: context.system,
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
}),
)
const webSocketEligible =
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
const http = webSocketEligible
? undefined
: SessionModelHttp.middleware(hooks, {
@@ -252,7 +258,7 @@ export const layer = Layer.effect(
...(webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
model.route.id === "openai-responses"
request.model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
+1 -1
View File
@@ -54,7 +54,7 @@ const layer = Layer.effect(
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
get: Effect.fnUntraced(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
+18 -15
View File
@@ -14,6 +14,7 @@ import { PluginHooks } from "../plugin/hooks.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHook } from "./model-hook.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
@@ -80,23 +81,25 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const request = yield* SessionModelHook.apply(
dependencies.hooks,
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
}),
)
const streamed = yield* dependencies.llm
.stream(
LLM.request({
model: resolved.model,
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
system: agent.system,
messages: [Message.user(firstUser.text)],
tools: [],
.stream(request, {
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
{
http: SessionModelHttp.middleware(dependencies.hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
},
)
})
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
+2 -2
View File
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
@@ -153,7 +153,7 @@ export const layer = (options?: ShellSelect.Options) =>
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
+2 -2
View File
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (
export const scan = Effect.fnUntraced(function* (
command: string,
shell: string,
cwd: string,
@@ -163,7 +163,7 @@ export const scan = Effect.fn("ShellParse.scan")(function* (
return yield* scanLegacy(command, shell, cwd)
})
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
+1 -4
View File
@@ -97,10 +97,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const materialize = Effect.fnUntraced(function* () {
const next = options.initial()
const api = options.draft(next)
for (const transform of transforms)
yield* apply(transform.run, api).pipe(
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
)
for (const transform of transforms) yield* apply(transform.run, api)
yield* commit(next)
})
+9 -28
View File
@@ -6,9 +6,8 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Config } from "./config.js"
import { Identifier } from "./id/id.js"
import type { DeepMutable } from "./schema.js"
import { State } from "./state.js"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KiB
@@ -17,16 +16,7 @@ export const DIRECTORY = "tool-output"
type Result = Tool.Result
export interface Policy {
readonly maxLines: number
readonly maxBytes: number
}
export interface Draft {
readonly update: (update: (policy: DeepMutable<Policy>) => void) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -56,23 +46,19 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface,
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const state = State.create<DeepMutable<Policy>, Draft>({
name: "tool-output",
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
draft: (policy) => ({
update: (update) => update(policy),
}),
})
const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) {
const truncate = Effect.fnUntraced(function* (result: Result) {
if (result.metadata?.truncated !== undefined) return result
const content =
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
const { maxLines, maxBytes } = state.get()
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? MAX_LINES
const maxBytes = configured?.max_bytes ?? MAX_BYTES
const lines = text.split("\n")
if (text.endsWith("\n")) lines.pop()
const totalBytes = Buffer.byteLength(text, "utf-8")
@@ -127,12 +113,7 @@ const layer = Layer.effect(
}
})
return Service.of({
truncate,
cleanup: () => cleanup(fs, directory),
transform: state.transform,
reload: state.reload,
})
return Service.of({ truncate, cleanup: () => cleanup(fs, directory) })
}),
)
@@ -156,5 +137,5 @@ const cleanupNode = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, cleanupNode],
deps: [Config.node, FSUtil.node, Global.node, cleanupNode],
})
+1 -1
View File
@@ -50,7 +50,7 @@ const layer = Layer.effect(
const image = yield* Image.Service
type NormalizedItem = Tool.Content | "decode" | "size"
const normalizeImages = Effect.fn("Tool.normalizeImages")(function* (content: ReadonlyArray<Tool.Content>) {
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1]
+2 -2
View File
@@ -208,7 +208,7 @@ export const Plugin = {
)
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const captureShell = Effect.fnUntraced(function* () {
const configured = Config.latest(yield* config.entries(), "tool_output")
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
@@ -228,7 +228,7 @@ export const Plugin = {
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const settleShell = Effect.fnUntraced(function* () {
const final = yield* shell.wait(info.id)
const capture = yield* captureShell()
+6 -11
View File
@@ -4,7 +4,6 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@@ -30,7 +29,6 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
@@ -97,9 +95,7 @@ export const Plugin = {
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* config.update((draft) => {
draft.websearch = false
})
yield* websearch.select(false)
return yield* new WebSearch.DisabledError()
}
const selection =
@@ -131,11 +127,7 @@ export const Plugin = {
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
@@ -206,7 +198,10 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
const disabled = Config.latest(yield* config.entries(), "websearch") === false
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
}),
)
+22 -8
View File
@@ -1,9 +1,10 @@
export * as WebSearch from "./websearch.js"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js"
export const ID = WebSearch.ID
@@ -24,6 +25,10 @@ export type Result = WebSearch.Result
export const Response = WebSearch.Response
export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
export interface ProviderImplementation extends Provider {
readonly execute: (input: ProviderInput) => Effect.Effect<readonly Result[], unknown>
}
@@ -49,6 +54,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
export interface Interface extends State.Transformable<Draft> {
readonly providers: () => Effect.Effect<readonly Provider[]>
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
readonly select: (selection: Selection) => Effect.Effect<void>
readonly query: (input: Input) => Effect.Effect<Response, Error>
}
@@ -56,14 +62,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
selection?: ID | "random" | false
selection?: Selection
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | "random" | false | undefined
set: (selection: ID | "random" | false) => void
get: () => Selection | undefined
set: (selection: Selection) => void
}
}
@@ -71,6 +77,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
@@ -91,12 +98,16 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
if (data.selection === false) return yield* new DisabledError()
if (data.selection === "random") {
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
const decoded = Schema.decodeUnknownOption(Selection)(stored)
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
const selection = data.selection ?? Option.getOrUndefined(decoded)
if (selection === false) return yield* new DisabledError()
if (selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return data.selection ? data.providers.get(data.selection) : undefined
return selection ? data.providers.get(selection) : undefined
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@@ -120,6 +131,9 @@ const layer = Layer.effect(
const provider = yield* defaultProvider()
return provider && { id: provider.id, name: provider.name }
}),
select: Effect.fn("WebSearch.select")(function* (selection) {
yield* kv.set(ProviderKey, selection)
}),
query: Effect.fn("WebSearch.query")(function* (input) {
const provider = yield* resolve(input)
const results = yield* provider.execute({ query: input.query }).pipe(
@@ -135,5 +149,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node],
deps: [Bus.node, KV.node],
})
+72 -44
View File
@@ -1,58 +1,86 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { Effect, Layer } from "effect"
import { host } from "../plugin/host"
import { it } from "../lib/effect"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Document, Event, Info, type Entry } from "@opencode-ai/schema/config"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(Layer.merge(PluginTestLayer, AppNodeBuilder.build(Image.node)))
const decode = Schema.decodeUnknownSync(Info)
const content = {
uri: "file:///pixel.png",
content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
encoding: "base64" as const,
mime: "image/png",
}
describe("ConfigImagePlugin.Plugin", () => {
it.live("materializes image policy from config", () =>
it.live("merges image limits and reloads changed config", () =>
Effect.gen(function* () {
const policy = {
autoResize: true,
maxWidth: 2_000,
maxHeight: 2_000,
maxBase64Bytes: 5 * 1024 * 1024,
}
const image = Image.Service.of({
normalize: () => Effect.die("unused image.normalize"),
reload: () => Effect.void,
transform: (callback) =>
Effect.sync(() => {
callback({ update: (update) => update(policy) })
return { dispose: Effect.void }
}),
})
yield* ConfigImagePlugin.Plugin.effect(host()).pipe(
Effect.provideService(Image.Service, image),
Effect.provide(
Config.testLayer([
new Document({
type: "document",
info: new Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({
auto_resize: false,
max_width: 1_000,
max_height: 800,
max_base64_bytes: 123_456,
}),
}),
}),
}),
]),
const image = yield* Image.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect(yield* limits(image)).toEqual({ maxWidth: 1_200, maxHeight: 900, maxBytes: 1 })
yield* config.setEntries([document({ auto_resize: false, max_width: 700, max_base64_bytes: 1 })])
yield* bus.publish(Event.Updated, {})
yield* waitUntil(
limits(image).pipe(
Effect.map((current) => current.maxWidth === 700 && current.maxHeight === 2_000 && current.maxBytes === 1),
),
)
}).pipe(
Effect.provide(
Config.testLayer([
document({ auto_resize: false, max_width: 1_200 }),
document({ max_height: 900, max_base64_bytes: 1 }),
]),
),
),
)
expect(policy).toEqual({
autoResize: false,
maxWidth: 1_000,
maxHeight: 800,
maxBase64Bytes: 123_456,
it.live("refetches config after subscribing to updates", () =>
Effect.gen(function* () {
const image = yield* Image.Service
const plugins = yield* Plugin.Service
let reads = 0
const config = Config.Service.of({
entries: () => Effect.sync(() => [document({ max_width: reads++ === 0 ? 1_200 : 700, max_base64_bytes: 1 })]),
update: () => Effect.die(new Error("Config update is unavailable")),
changes: () => Stream.empty,
})
yield* ConfigImagePlugin.Plugin.effect(yield* PluginHost.make(plugins)).pipe(
Effect.provideService(Config.Service, config),
)
expect(yield* limits(image)).toEqual({ maxWidth: 700, maxHeight: 2_000, maxBytes: 1 })
}),
)
})
function document(image: NonNullable<typeof Info.Encoded.media>["image"]): Entry {
return new Document({ type: "document", info: decode({ media: { image } }) })
}
const limits = Effect.fnUntraced(function* (image: Image.Interface) {
const error = yield* image.normalize("pixel.png", content).pipe(Effect.flip, Effect.orDie)
if (error._tag !== "Image.SizeError") return yield* Effect.die(error)
return { maxWidth: error.maxWidth, maxHeight: error.maxHeight, maxBytes: error.maxBytes }
})
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for image config reload"))
})
+23 -2
View File
@@ -44,7 +44,9 @@ describe("PluginSupervisor config", () => {
const plugins = yield* Plugin.Service
yield* ready()
expect(
(yield* plugins.list()).map((plugin) => plugin.id).filter((id) => id.startsWith("opencode.provider.")),
(yield* plugins.list())
.flatMap((plugin) => (plugin.id ? [plugin.id] : []))
.filter((id) => id.startsWith("opencode.provider.")),
).toEqual([Plugin.ID.make("opencode.provider.openai")])
}),
),
@@ -64,10 +66,20 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded from config",
mode: "subagent",
})
expect((yield* plugins.list()).find((plugin) => plugin.id === "config-promise-plugin")).toEqual({
id: Plugin.ID.make("config-promise-plugin"),
source: {
type: "local",
path: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"),
},
status: "active",
tui: true,
})
}),
),
)
@@ -143,6 +155,7 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
expect(yield* agents.get(Agent.ID.make("configured"))).toMatchObject({
description: "Loaded after invalid plugins",
})
@@ -150,6 +163,12 @@ describe("PluginSupervisor config", () => {
path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts"),
path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts"),
])
expect(
(yield* plugins.list()).filter((plugin) => plugin.status === "failed").map((plugin) => plugin.source),
).toEqual([
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/missing-plugin.ts") },
{ type: "local", path: path.join(import.meta.dir, "../plugin/fixtures/invalid-plugin.ts") },
])
}),
).pipe(Effect.provide(Logger.layer([logger])))
})
@@ -246,10 +265,12 @@ describe("PluginSupervisor config", () => {
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
const inventory = yield* plugins.list()
const ids = inventory.map((plugin) => String(plugin.id))
expect(ids).toContain("opencode.agent")
expect(ids).toContain("static-sdk")
expect(ids).not.toContain("config-promise-plugin")
expect(inventory.find((plugin) => plugin.id === "static-sdk")?.source).toEqual({ type: "sdk" })
const agents = yield* Agent.Service
expect(yield* agents.get(Agent.ID.make("directory"))).toBeUndefined()
+26 -1
View File
@@ -6,7 +6,7 @@ import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect } from "effect"
import { Effect, Tracer } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { isSqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
@@ -49,6 +49,31 @@ test("selects rows through Effect-yieldable query builders", async () => {
)
})
test("suppresses statement spans", async () => {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
await Effect.runPromise(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }))
yield* db.select().from(users)
}).pipe(
Effect.provideService(Tracer.Tracer, tracer),
Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
Effect.scoped,
),
)
expect(spans.map((span) => span.name)).not.toContain("sql.execute")
})
test("commits successful transactions", async () => {
await run(
Effect.gen(function* () {
+187 -141
View File
@@ -1,40 +1,30 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Fiber } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
import { Document, Info } from "@opencode-ai/schema/config"
import { Config } from "../src/config"
import { ConfigFormatterPlugin } from "../src/config/plugin/formatter"
import { Info } from "@opencode-ai/schema/config"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Formatter } from "../src/formatter"
import { Location } from "../src/location"
import { location } from "./fixture/location"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(Layer.empty)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
]),
)
type ConfigInput = typeof Info.Encoded
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
const layer = AppNodeBuilder.build(Formatter.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
])
const entries =
configured === undefined
? []
: [new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ formatter: configured }) })]
return Layer.effectDiscard(ConfigFormatterPlugin.Plugin.effect(host()).pipe(Effect.provide(Config.testLayer(entries)))).pipe(
Layer.provideMerge(layer),
)
}
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -43,152 +33,208 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
)
}
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(
Effect.provide(
formatterLayer(directory, {
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
}),
function withFormatter<A, E, R>(
configured: ConfigInput["formatter"],
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
) {
return withTemp((directory) =>
Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
).pipe(
Effect.andThen(
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* body(yield* Formatter.Service, directory)
}).pipe(
Effect.scoped,
Effect.provide(
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
),
),
),
),
)
}
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
withFormatter(
{
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* formatter.file(file)).toBe(false)
}),
),
)
it.live("file() returns false when no formatter runs", () =>
withTemp((directory) =>
withFormatter(false, (formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.txt")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(Effect.provide(formatterLayer(directory, false))),
expect(yield* formatter.file(file)).toBe(false)
}),
),
)
it.live("loads formatter state per directory", () =>
withTemp((off) =>
withTemp((on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
Effect.provide(formatterLayer(off, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.provide(
formatterLayer(on, {
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
}),
),
)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
withFormatter(false, (disabledFormatter, off) =>
withFormatter(
{
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
},
(enabledFormatter, on) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* disabledFormatter.file(offFile)
const enabled = yield* enabledFormatter.file(onFile)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
}),
),
),
)
it.live("stops after the first matching formatter succeeds", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
}),
),
),
withFormatter(
{
first: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
"$FILE",
],
extensions: [".seq"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".seq"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.seq")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
}),
),
)
it.live("tries the next matching formatter when the first fails", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}).pipe(
Effect.provide(
formatterLayer(directory, {
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
}),
),
),
withFormatter(
{
first: {
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
extensions: [".fallback"],
},
second: {
command: [
process.execPath,
"-e",
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
"$FILE",
],
extensions: [".fallback"],
},
},
(formatter, directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.fallback")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
}),
),
)
it.live("resolves a replacement formatter command independently", () =>
withTemp((directory) =>
it.live("rebuilds formatter state and clears resolved commands", () =>
withFormatter(false, (formatter, directory) =>
Effect.gen(function* () {
const formatter = yield* Formatter.Service
const file = path.join(directory, "test.replaced")
const register = (content: string) =>
formatter.transform((draft) => {
draft.set("replacement", {
name: "replacement",
extensions: [".replaced"],
enabled: Effect.succeed([
process.execPath,
"-e",
`require('fs').writeFileSync(process.argv.at(-1), '${content}')`,
"$FILE",
]),
})
const command = { suffix: "A" }
yield* formatter.transform((draft) => {
const suffix = command.suffix
draft.set({
name: "reload",
extensions: [".reload"],
enabled: Effect.succeed([
process.execPath,
"-e",
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
"$FILE",
]),
})
yield* register("first")
})
const file = path.join(directory, "test.reload")
yield* Effect.promise(() => fs.writeFile(file, "x"))
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("first")
yield* register("second")
command.suffix = "B"
yield* formatter.reload()
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("second")
}).pipe(Effect.provide(formatterLayer(directory, false))),
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
}),
),
)
it.live("does not cache a command resolved before reload", () =>
withFormatter(false, (formatter, directory) =>
Effect.gen(function* () {
const resolving = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const command = { suffix: "A" }
yield* formatter.transform((draft) => {
const suffix = command.suffix
const resolved = [
process.execPath,
"-e",
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
"$FILE",
]
draft.set({
name: "reload-race",
extensions: [".race"],
enabled:
suffix === "A"
? Deferred.succeed(resolving, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as(resolved),
)
: Effect.succeed(resolved),
})
})
const file = path.join(directory, "test.race")
yield* Effect.promise(() => fs.writeFile(file, "x"))
const first = yield* formatter.file(file).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(resolving)
command.suffix = "B"
yield* formatter.reload()
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(first)).toBe(true)
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
}),
),
)
})
-2
View File
@@ -4,6 +4,4 @@ import { Effect, Layer } from "effect"
/** Passthrough resizer for tests that build Tool.node without a Location. */
export const imagePassthrough = Layer.mock(Image.Service, {
normalize: (_resource, content) => Effect.succeed(content),
transform: () => Effect.die("unused image.transform"),
reload: () => Effect.die("unused image.reload"),
})
+10 -2
View File
@@ -428,10 +428,18 @@ describe("LocationServiceMap", () => {
),
)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* registry.list()).length === 0) break
if ((yield* registry.list()).some((plugin) => plugin.status === "failed")) break
yield* Effect.sleep("20 millis")
}
expect(yield* registry.list()).toEqual([])
expect(yield* registry.list()).toEqual([
{
id: Plugin.ID.make("failing-plugin"),
source: { type: "local", path: path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts") },
status: "failed",
error: expect.stringContaining("plugin failed"),
tui: false,
},
])
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
for (let attempt = 0; attempt < 100; attempt++) {
+48 -44
View File
@@ -8,7 +8,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -17,8 +16,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
import fs from "fs/promises"
import path from "path"
const it = testEffect(PluginTestLayer)
@@ -92,41 +89,7 @@ describe("Plugin", () => {
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
expect(routed).toEqual([
"add:/target",
"remove:/target",
"connect:/target",
"disconnect:/target",
"list:/target",
])
}),
)
it.effect("registers formatters through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const host = yield* PluginHost.make(plugins)
const file = path.join(location.directory, "plugin.formatter-test")
yield* Effect.promise(() => fs.writeFile(file, "before"))
const registration = yield* host.formatter.transform((draft) => {
draft.add({
name: "plugin",
command: [
process.execPath,
"-e",
"const fs = require('fs'); fs.writeFileSync(process.argv.at(-1), 'after')",
"$FILE",
],
extensions: [".formatter-test"],
})
})
expect(yield* formatter.file(file)).toBe(true)
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("after")
yield* registration.dispose
expect(yield* formatter.file(file)).toBe(false)
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
}),
)
@@ -169,9 +132,22 @@ describe("Plugin", () => {
expect(updates).toBe(2)
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("second")
yield* plugins.activate(
[versioned(managed(), "2")],
[
{
source: { type: "package", package: "broken" },
status: "failed",
error: "failed to resolve",
tui: false,
},
],
)
expect(updates).toBe(3)
yield* plugins.activate([])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
expect(updates).toBe(3)
expect(updates).toBe(4)
yield* unsubscribe
}),
)
@@ -191,7 +167,7 @@ describe("Plugin", () => {
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(yield* plugins.list()).toEqual([{ id: active }])
expect(yield* plugins.list()).toEqual([{ id: active, source: { type: "builtin" }, status: "active", tui: false }])
}),
)
@@ -220,12 +196,24 @@ describe("Plugin", () => {
})
yield* plugins.activate([versioned(good), versioned(bad)])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{
id: Plugin.ID.make("bad"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("materialization failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("loaded")
fail = false
yield* plugins.activate([versioned(good), versioned(bad, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }, { id: Plugin.ID.make("bad") }])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("good"), source: { type: "builtin" }, status: "active", tui: false },
{ id: Plugin.ID.make("bad"), source: { type: "builtin" }, status: "active", tui: false },
])
}),
)
@@ -260,7 +248,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("managed") }])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("previous")
}),
)
@@ -292,7 +288,15 @@ describe("Plugin", () => {
yield* plugins.activate([versioned(previous)])
yield* plugins.activate([versioned(replacement, "2")])
expect(yield* plugins.list()).toEqual([])
expect(yield* plugins.list()).toEqual([
{
id: Plugin.ID.make("managed"),
source: { type: "builtin" },
status: "failed",
error: expect.stringContaining("replacement failed"),
tui: false,
},
])
expect(yield* agents.get(Agent.ID.make("configured"))).toBeUndefined()
}),
)
-2
View File
@@ -10,7 +10,6 @@ import { Bus } from "@opencode-ai/core/bus"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Formatter } from "@opencode-ai/core/formatter"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
@@ -45,7 +44,6 @@ export const PluginTestLayer = LayerNode.compile(
Credential.node,
Bus.node,
Form.node,
Formatter.node,
LayerNodePlatform.httpClient,
Plugin.node,
Agent.node,
@@ -2,6 +2,7 @@ import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "config-promise-plugin",
tui: true,
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("configured", (agent) => {
-4
View File
@@ -48,10 +48,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
formatter: overrides.formatter ?? {
transform: () => Effect.die("unused formatter.transform"),
reload: () => Effect.die("unused formatter.reload"),
},
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
+16 -4
View File
@@ -320,10 +320,14 @@ describe("fromPromise", () => {
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http.request", (event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
})
await ctx.session.hook(
"http.request",
(event) => {
event.request = new Request("https://provider.test/changed", event.request)
event.request.headers.set("x-hook", "promise")
},
{ providerID: "test" },
)
await ctx.session.hook("http.response", async (event) => {
event.response = new Response(`${await event.response.text()}-response`, {
status: event.response.status,
@@ -342,6 +346,11 @@ describe("fromPromise", () => {
...context,
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
})
const ignored = yield* hooks.trigger("session", "http.request", {
...context,
model: Model.Ref.make({ providerID: Provider.ID.make("other"), id: Model.ID.make("model") }),
request: new Request("https://other.test"),
})
const response = yield* hooks.trigger("session", "http.response", {
...context,
request: request.request,
@@ -349,6 +358,9 @@ describe("fromPromise", () => {
})
expect(request.request.url).toBe("https://provider.test/changed")
expect(ignored.request.url).toBe("https://other.test/")
expect(yield* hooks.has("session", "http.request", Provider.ID.make("test"))).toBe(true)
expect(yield* hooks.has("session", "http.request", Provider.ID.make("other"))).toBe(false)
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
}),
)
@@ -1,17 +1,25 @@
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ConfigProvider, DateTime, Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -24,19 +32,33 @@ const addPlugin = Effect.fn(function* () {
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
})
const addGithubCopilotPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* GithubCopilotPlugin.effect(host)
})
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "model.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: new Request(url, { method: "POST", body: "{}" }),
baseURL,
headers: {},
})
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
return {
baseURL: event.baseURL,
headers: event.headers,
hasHttpHooks:
(yield* hooks.has("session", "http.request", providerID)) ||
(yield* hooks.has("session", "http.response", providerID)),
}
})
describe("OpenAIPlugin", () => {
@@ -110,18 +132,19 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
const custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1")
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(direct.hasHttpHooks).toBe(false)
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
@@ -167,16 +190,77 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator")
expect(direct.headers).not.toHaveProperty("originator")
expect(direct.hasHttpHooks).toBe(false)
expect(provider.headers).not.toHaveProperty("originator")
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("selects WebSocket with the built-in provider hooks enabled", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
value: Credential.Key.make({ type: "key", key: "sk-test" }),
})
yield* addPlugin()
yield* addGithubCopilotPlugin()
const executor = { execute: () => Effect.die("unused WebSocket execution") }
const transport = SessionModelTransport.Service.of({
bind: () => executor,
close: () => Effect.void,
closeAll: Effect.void,
})
const sessionID = Session.ID.make("ses_websocket_hooks")
const agentID = Agent.ID.make("build")
const agent = Agent.Info.make(Agent.Info.default(agentID))
const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
})
const program = Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service
return yield* requests.prepare({
context: {
session: Session.Info.make({
id: sessionID,
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
}),
agent: { id: agentID, info: agent },
model,
initial: "",
messages: [],
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
},
step: 1,
})
}).pipe(
Effect.provide(SessionModelRequest.layer),
Effect.provideService(SessionModelTransport.Service, transport),
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
),
),
)
const prepared = yield* program
expect(prepared.webSocketEligible).toBe(true)
expect(prepared.options.webSocket).toBe(executor)
expect(prepared.options.http).toBeUndefined()
}),
)
})
+18 -3
View File
@@ -73,7 +73,7 @@ const it = testEffect(
)
describe("Session.compact", () => {
it.effect("durably stacks manual compaction", () =>
it.effect("durably coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* Session.Service
@@ -102,11 +102,10 @@ describe("Session.compact", () => {
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(second.id).not.toBe(first.id)
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(created.id)).toEqual([
expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
])
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
@@ -115,4 +114,20 @@ describe("Session.compact", () => {
expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
}),
)
it.effect("coalesces concurrent manual compaction", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const admitted = yield* Effect.all(
[SessionMessage.ID.create(), SessionMessage.ID.create()].map((id) =>
session.compact({ id, sessionID: created.id }),
),
{ concurrency: "unbounded" },
)
expect(admitted[1]?.id).toBe(admitted[0]?.id)
expect(yield* session.inbox(created.id)).toHaveLength(1)
}),
)
})
@@ -291,18 +291,25 @@ it.effect(
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
let modelRequestHook = false
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
yield* hooks.register("session", "model.request", () =>
Effect.sync(() => {
modelRequestHook = true
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(modelRequestHook).toBe(true)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
+219 -1
View File
@@ -4,8 +4,10 @@ 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"
@@ -15,6 +17,8 @@ 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"
@@ -28,6 +32,16 @@ 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", () => {
@@ -60,6 +74,43 @@ 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
@@ -68,6 +119,109 @@ 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({
@@ -87,12 +241,33 @@ 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 : item.durable?.seq)),
items.map((item): number | string | undefined =>
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
),
).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
@@ -121,3 +296,46 @@ 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) }])
}),
)
})
+19 -5
View File
@@ -27,6 +27,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
@@ -59,12 +60,25 @@ const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// Attachment admission only needs the location-scoped Image service.
// Attachment admission only needs image normalization and plugin readiness.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
}) as unknown as Layer.Layer<LocationServices>,
Layer.unwrap(
Effect.sync(() => {
let ready = false
return Layer.mergeAll(
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
ready
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
: Effect.die(new Error("Image service used before plugins were ready")),
}),
Layer.succeed(
PluginSupervisor.Service,
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
),
)
}),
) as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
+30
View File
@@ -1020,6 +1020,36 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps WebSocket eligibility after model request hooks", () =>
Effect.gen(function* () {
yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-model-request-hook"] = "active"
}),
)
yield* hooks.register("session", "http.request", () => Effect.die("Other-provider HTTP hook should not apply"), {
providerID: Provider.ID.githubCopilot,
})
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const selected = yield* context.select(sessionID)
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
const prepared = yield* modelRequests.prepare({
context: yield* context.load(selected),
step: 1,
})
expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active")
expect(prepared.webSocketEligible).toBe(true)
expect(prepared.options.http).toBeUndefined()
}),
)
it.effect("forces HTTP and triggers active request and response hooks once", () =>
Effect.gen(function* () {
yield* setup
@@ -0,0 +1,87 @@
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))
})
}),
)
})
+3 -7
View File
@@ -1,8 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -13,7 +12,6 @@ import { Global } from "@opencode-ai/util/global"
import { Identifier } from "@opencode-ai/core/id/id"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
import { host } from "./plugin/host"
const withStore = <A, E, R>(
body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
@@ -23,12 +21,10 @@ const withStore = <A, E, R>(
Effect.promise(() => tmpdir()),
(tmp) => {
const config = Config.testLayer([new Document({ type: "document", info })])
const base = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
[Config.node, config],
[Global.node, Global.layerWith({ data: tmp.path })],
])
const layer = Layer.effectDiscard(
ConfigToolOutputPlugin.Plugin.effect(host()).pipe(Effect.provide(config)),
).pipe(Layer.provideMerge(base))
return Effect.gen(function* () {
return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
}).pipe(Effect.provide(layer))

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