mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-19 08:06:03 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
177370dae0 |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -82,7 +82,6 @@ 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
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
# Sync Engine Plan (draft)
|
||||
|
||||
Redesign of client state management: consume the event-sourced server that
|
||||
already exists. Goal: **less code, more reliable** — optimism, reconnect, and
|
||||
retry become structural guarantees instead of defended special cases.
|
||||
|
||||
Status: outline for discussion. Sections marked `TODO` get filled in as we talk.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context ledger (receipts — do not lose these)
|
||||
|
||||
Facts established by code audit and prototyping, with locations.
|
||||
|
||||
### The server is already event-sourced
|
||||
|
||||
- Durable per-session event log in SQLite: `event(aggregate_id, seq, type, data)`
|
||||
+ `event_sequence` counter, unique index `(aggregate_id, seq)`
|
||||
(`packages/core/src/event/sql.ts`, migration `20260323234822_events`).
|
||||
- Event append, projections, and `commit` hooks all run in **one SQLite
|
||||
transaction** (`packages/core/src/bus.ts:249-370`, `behavior: "immediate"`).
|
||||
- 40 of 46 session event types are durable; the 6 ephemeral are streaming
|
||||
deltas/progress/usage ticks (`packages/schema/src/session-event.ts:626`).
|
||||
Durable events carry complete facts (`text.ended` has full text).
|
||||
- All hydration tables are written **only** by projectors inside the append
|
||||
transaction: `SessionMessageTable`, `SessionTable` → `session/projector.ts`;
|
||||
`SessionInboxTable` → `inbox.ts` `project*` fns called only from
|
||||
`projector.ts:521,555,571,577`. `SessionPendingTable` is dead (zero writers).
|
||||
- Message rows carry the aggregate seq of their creating event
|
||||
(`projector.ts:386`), and `Session.messages` already paginates by it
|
||||
(`session.ts:501`).
|
||||
- Replay+live log endpoint exists:
|
||||
`GET /api/experimental/session/:id/log?after=seq&follow=true`
|
||||
(`packages/protocol/src/groups/session.ts:631`). Only devtools consumes it.
|
||||
- Idempotent admission ~exists: `SessionInbox.admit` (`inbox.ts:137`) adopts an
|
||||
existing client-supplied `SessionMessage.ID`, re-checks after conflict
|
||||
defects. Gap: adopts without comparing payloads (no conflict on mismatch).
|
||||
- Reverts are committed server-side inside `Session.prompt`
|
||||
(`core/src/session.ts:567` at audit time) — client-side revert.commit is
|
||||
already redundant.
|
||||
|
||||
### The client ignores all of it
|
||||
|
||||
- The client data layer is `packages/client/src/solid/data.ts` (~1,530 lines;
|
||||
`packages/tui/src/context/data.tsx` is now a 22-line wrapper) — shared by
|
||||
TUI/desktop/web, so the engine lands once for all clients. Three racing
|
||||
write paths into one Solid store: a ~60-case bus-event switch, fetch
|
||||
handlers doing wholesale `reconcile()`, and (in PR #42807) an optimistic
|
||||
ledger both must consult. `sync.complete(key)` markers dedupe fetches but
|
||||
are not watermarks.
|
||||
- Opening a session today = four separate fetches: `session.get`,
|
||||
`session.list({parentID})` (children), `session.inbox.list`, messages
|
||||
(`data.ts:1084-1140`) — four sync keys, no shared consistent point.
|
||||
- Reconnect = `sync.reset()` + refetch everything; no gap proof.
|
||||
|
||||
### Prior art
|
||||
|
||||
- PRs #42807 (optimistic message send) and #42808 (optimistic session create):
|
||||
the stopgap. They landed client-generated IDs (keep) and ~15 hand-placed
|
||||
guard sites defending the optimistic ledger (delete when this plan ships).
|
||||
- Prototype: `~/code/open-source/sync-proto` — Effect v4 + Solid. Engine core
|
||||
~200 lines. Six laws as passing tests; seeded 2-client chaos sim (lost
|
||||
requests, lost responses after durable write, rejections, connection cuts,
|
||||
latency shifts) with convergence / ordering / uniqueness / no-flicker
|
||||
invariants; sim caught a real protocol bug (validation-before-adoption) on
|
||||
first run. Two-pane demo (await vs optimistic) with narrated scenarios.
|
||||
- Proposal doc: `~/code/open-source/opencode-sync-proposal/PROPOSAL.md`
|
||||
("Intents, Not Mutations").
|
||||
|
||||
### Vocabulary
|
||||
|
||||
- **fold** — pure reduction of durable events into state; same meaning on both
|
||||
sides. **intent** — a user action not yet acknowledged, client ID, in the
|
||||
**outbox**. **watermark** — the aggregate seq a snapshot reflects.
|
||||
**echo determinism** — an intent renders byte-identical to the row its
|
||||
admission event folds to, so acks are invisible.
|
||||
- The six laws: idempotency, echo determinism, sync opacity, ordering,
|
||||
convergence, failure atomicity.
|
||||
|
||||
### Ordering constraint (the one subtle thing)
|
||||
|
||||
Watermark reads must share a transaction with row reads:
|
||||
`seq`-then-rows double-applies accumulators; rows-then-`seq` loses events.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Optimism, reconnect, and retry are each defended point-by-point instead of
|
||||
guaranteed structurally; every new sync path re-fights the same races.
|
||||
- The client maintains a hand-written mutable mirror of state the server can
|
||||
already replay deterministically.
|
||||
|
||||
## 2. The claim: less code, more reliable
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Fold cases ≈ today's event switch (that code stays, becomes pure).
|
||||
- Deleted: optimistic ledger + guards, echo replace-in-place, sync-survival
|
||||
merges, `submitTails` throttling, `sync.complete` session keys,
|
||||
refetch-everything reconnect.
|
||||
- Added: engine core (~200 lines proven in prototype) + watermark plumbing.
|
||||
- Reliability from structure: the six laws hold by construction, not vigilance.
|
||||
|
||||
## 3. Design
|
||||
|
||||
```
|
||||
state = fold(snapshot, durable events) server truth only
|
||||
outbox = ordered pending intents client IDs, idempotent, serial resend
|
||||
overlay = live ephemeral fragments deltas/progress, superseded by durable facts
|
||||
view = render(state ⊕ outbox ⊕ overlay) derived
|
||||
```
|
||||
|
||||
The entire client surface for one session — two calls, one stream:
|
||||
|
||||
```
|
||||
GET /session/:id/snapshot fetch → { session, children, inbox,
|
||||
messages: last(N), seq }
|
||||
GET /session/:id/log?after=seq&follow=true one ordered stream:
|
||||
replay: durable events seq+1..head (deltas are live-only, absent here)
|
||||
marker: log.synced "caught up" — first-class engine signal
|
||||
live: durable + ephemeral session events interleaved, publish order
|
||||
```
|
||||
|
||||
- **Decided:** the log endpoint's follow phase carries ephemeral session
|
||||
events too (widen its union from `Durable | Synced`). One server-merged
|
||||
stream per session; the client never merges two event feeds for one
|
||||
aggregate, so a delta can never precede its own `Started`.
|
||||
- **Overlay semantics:** `Map<(messageID, ordinal), accumulated>` — never
|
||||
enters the fold. The durable full-value boundary (`text.ended`, carries
|
||||
complete text) supersedes and clears the overlay entry in the same atomic
|
||||
update that folds it — same no-seam trick as an intent leaving the outbox
|
||||
on ack. Dropped deltas are self-healing by construction. Same mechanism for
|
||||
all 6 ephemeral types (`tool.progress`/`usage.updated` superseded by
|
||||
`tool.success|failed`/`step.ended`).
|
||||
- **Decided (mid-stream reconnect):** accept today's behavior — after
|
||||
re-hydrate, overlay accumulates from now, so in-flight text may render with
|
||||
a missing prefix until its `Ended` supersedes. Simplest code, self-healing,
|
||||
no protocol change. (Optional later polish: if `Started` wasn't observed
|
||||
live, show a streaming indicator instead of partial text.)
|
||||
- Overlay entries render only once their base part exists in folded state
|
||||
(covers the one degenerate case: a live delta arriving during the replay
|
||||
window).
|
||||
- **Outbox ack contract: the echo is the only ack.** Intents carry a
|
||||
client-minted message ID. The HTTP 2xx is a latency hint, never trusted;
|
||||
the intent leaves the outbox only when the durable event carrying its ID
|
||||
folds in (same atomic store update — no flicker), or on a typed rejection
|
||||
(drop + surface). Timeout/disconnect = unknown fate = intent just stays.
|
||||
On reconnect: tail first, wait for `log.synced` (replay acks anything that
|
||||
landed), then resend unacked serially — adopt-on-same-ID (`inbox.ts:137`)
|
||||
makes resend duplicate-free. Lost request and lost response collapse into
|
||||
the same case ("no echo yet"), which is why no guard machinery survives.
|
||||
- **Contract: the snapshot is the only entry point.** The engine never replays
|
||||
a session from genesis; fold's domain is "snapshot + events after its seq."
|
||||
Forced by `session.forked` — the event carries only `{ parentID, boundary }`
|
||||
and the projector copies parent rows, which no client fold could reproduce.
|
||||
Bonus: licenses future log truncation (serve back to the oldest live
|
||||
snapshot, not seq 0).
|
||||
- Compaction/revert/fork all fold cleanly under that contract: compaction only
|
||||
adds (summary message + epoch marker); revert folds exactly what the
|
||||
projector does (drop messages/inbox at-or-after boundary, clear marker —
|
||||
`projector.ts:660-690`); fork needs no fold case at all (a forked session's
|
||||
snapshot already contains the copied messages).
|
||||
- Reconnect: tail from last seq first; on typed seq-unavailable → snapshot →
|
||||
resend unacknowledged → tail. Gapless by construction.
|
||||
- Scope boundary: session aggregate only. Ambient state (catalog, agents,
|
||||
projects, vcs, worktrees) stays on the existing global bus as-is.
|
||||
- Render target: existing Solid store shape, so components don't change.
|
||||
|
||||
## 4. Server changes (small)
|
||||
|
||||
- S1: watermarked hydration — **decided (tentative, pending prior-art review):**
|
||||
one new endpoint, `GET /api/session/:id/snapshot`, one read transaction:
|
||||
|
||||
```
|
||||
GET /api/session/:id/snapshot?recent=N
|
||||
→ { session, children, inbox, messages: last(N), seq }
|
||||
```
|
||||
|
||||
`recent` defaults to 200 — today's first page exactly (`data.ts:1136`,
|
||||
`limit: 200`). Tuning it smaller later is a parameter change, not a design
|
||||
change.
|
||||
|
||||
- Named `snapshot`, not `state`: point-in-time consistency is the contract,
|
||||
and `state` collides with the engine's `state = fold(...)`.
|
||||
- One transaction → one watermark. Rejected alternative: adding `seq` to
|
||||
each existing read — three watermarks force per-slice replay guards in
|
||||
the engine, and the session row's accumulators (usage sums) double-count
|
||||
on overlap replay. Server savings reappear as engine complexity, worse.
|
||||
- `children` are info rows only; child sessions are separate aggregates and
|
||||
hydrate their own messages/inbox when opened.
|
||||
- Older history stays on the existing seq-cursored `messages` endpoint,
|
||||
folded in as inert backfill (IDs dedupe; revert/compaction arrive on the
|
||||
log).
|
||||
- Prior art for snapshot+watermark+tail: Discord (`seq`/RESUME), Telegram
|
||||
(`pts`/`getDifference`), Linear (`lastSyncId`), Postgres logical
|
||||
replication (exported snapshot + LSN), Kafka/EventStore catch-up
|
||||
subscriptions, Replicache (cookie — whose launch checklist requires the
|
||||
watermark "read in the same transaction as the client view data").
|
||||
Slack's deprecated `rtm.start` is the cautionary tale for unbounded
|
||||
snapshots (their edge cache cut boot payloads 7–44×; 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 S1–S4 (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,7 +905,6 @@ 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,34 +1010,6 @@ 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,7 +7,6 @@ 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 = {
|
||||
@@ -102,10 +101,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(pluginLabel))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -45,9 +44,7 @@ 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: pluginLabel(item) })),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,7 +6,6 @@ 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)
|
||||
@@ -39,7 +38,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(pluginLabel))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -42,8 +42,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -109,7 +109,7 @@ for (const item of targets) {
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
sourcemap: "inline",
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -64,22 +63,28 @@ try {
|
||||
})
|
||||
if (unauthorizedOpenApi.status !== 401)
|
||||
throw new Error("Compiled service exposed application routes without authentication")
|
||||
const stopRoute = await fetch(new URL("/api/service/stop", info.url), {
|
||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (stopRoute.status !== 404) throw new Error("Compiled service exposed the removed HTTP stop route")
|
||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
||||
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const loser = processes.find((process) => process.pid !== info.pid)
|
||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||
|
||||
await Effect.runPromise(
|
||||
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
||||
await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
|
||||
@@ -275,36 +275,15 @@ 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.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
||||
}),
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
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,
|
||||
),
|
||||
},
|
||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
||||
}),
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
params: { key: Argument.string("key") },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -19,21 +19,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const serviceStarts = yield* Queue.unbounded<{
|
||||
readonly reason: "missing" | "version-mismatch"
|
||||
readonly previousVersion?: string
|
||||
}>()
|
||||
yield* Queue.take(serviceStarts).pipe(
|
||||
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: requestedServer,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
@@ -14,18 +14,11 @@ 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) => name(a).localeCompare(name(b)))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).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,8 +7,6 @@ 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), Option.getOrUndefined(input.name))) + EOL,
|
||||
)
|
||||
process.stdout.write((yield* ServiceConfig.get(Option.getOrUndefined(input.key))) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } 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, Option.getOrUndefined(input.nestedValue))
|
||||
yield* ServiceConfig.set(input.key, input.value)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Effect } 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, Option.getOrUndefined(input.name))
|
||||
yield* ServiceConfig.unset(input.key)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,21 +59,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
|
||||
Effect.gen(function* () {
|
||||
yield* Heap.listen
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
|
||||
runFork(Effect.logError("uncaught exception", { cause, origin }))
|
||||
}
|
||||
const unhandledRejection = (cause: unknown) => {
|
||||
runFork(Effect.logError("unhandled rejection", { cause }))
|
||||
}
|
||||
process.on("uncaughtException", uncaughtException)
|
||||
process.on("unhandledRejection", unhandledRejection)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
process.off("uncaughtException", uncaughtException)
|
||||
process.off("unhandledRejection", unhandledRejection)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("cli starting", {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
@@ -82,12 +67,6 @@ Effect.gen(function* () {
|
||||
})
|
||||
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("cli process failed", {
|
||||
cause,
|
||||
args: process.argv.slice(2),
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
|
||||
@@ -117,6 +117,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: {
|
||||
instanceID,
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
@@ -179,36 +180,18 @@ const register = Effect.fnUntraced(function* (
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
|
||||
const owns = (found: Info) =>
|
||||
found.id === info.id &&
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("managed service registration check failed; shutting down", {
|
||||
cause,
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
}).pipe(Effect.andThen(Effect.failCause(cause))),
|
||||
),
|
||||
Effect.tap((found) =>
|
||||
owns(found)
|
||||
? Effect.void
|
||||
: Effect.logWarning("managed service registration replaced; shutting down", {
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
observedServiceID: found.id,
|
||||
observedServicePID: found.pid,
|
||||
observedVersion: found.version,
|
||||
observedURL: found.url,
|
||||
}),
|
||||
),
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.ignore,
|
||||
|
||||
@@ -15,11 +15,10 @@ 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", "env"] as const
|
||||
const keys = ["hostname", "port", "password"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -77,7 +76,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||
if (key === "hostname" || key === "port" || key === "password") return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -105,7 +104,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
|
||||
return {
|
||||
file,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
env: (yield* read()).env,
|
||||
command: [
|
||||
...selfCommand(),
|
||||
"serve",
|
||||
@@ -143,14 +141,12 @@ 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, name?: string) {
|
||||
export const get = Effect.fn("cli.service-config.get")(function* (key?: string) {
|
||||
if (key === undefined) {
|
||||
const { password: _password, ...safe } = yield* read()
|
||||
return JSON.stringify(safe, null, 2)
|
||||
}
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||
switch (selected) {
|
||||
switch (configKey(key)) {
|
||||
case "hostname": {
|
||||
return (yield* read()).hostname ?? ""
|
||||
}
|
||||
@@ -161,19 +157,12 @@ 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, nestedValue?: string) {
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && nestedValue !== undefined)
|
||||
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||
switch (selected) {
|
||||
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {
|
||||
switch (configKey(key)) {
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), hostname: value })
|
||||
@@ -191,20 +180,11 @@ 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, name?: string) {
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||
switch (selected) {
|
||||
export const unset = Effect.fn("cli.service-config.unset")(function* (key: string) {
|
||||
switch (configKey(key)) {
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { hostname: _hostname, ...next } = yield* read()
|
||||
@@ -223,15 +203,6 @@ 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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -60,44 +60,6 @@ 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")
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
"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",
|
||||
"bench:sync": "bun script/bench-session-sync.ts",
|
||||
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.test.json"
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,225 +0,0 @@
|
||||
// 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)
|
||||
@@ -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"
|
||||
@@ -41,8 +41,13 @@ import type { Config } from "@opencode-ai/schema/config"
|
||||
export type Endpoint0_0Output = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
|
||||
export type Endpoint0_1Input = { readonly instanceID: string }
|
||||
export type Endpoint0_1Output = { readonly accepted: boolean }
|
||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
||||
|
||||
export interface HealthApi<E = never> {
|
||||
readonly get: HealthGetOperation<E>
|
||||
readonly stop: HealthStopOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint1_0Output = { readonly urls: ReadonlyArray<string> }
|
||||
@@ -139,46 +144,36 @@ export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly recent?: number | undefined }
|
||||
export type Endpoint5_6Output = {
|
||||
readonly session: Session.Info
|
||||
readonly children: ReadonlyArray<Session.Info>
|
||||
readonly inbox: ReadonlyArray<SessionInbox.Info>
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly seq: Event.Seq
|
||||
}
|
||||
export type SessionSnapshotOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_6Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_7Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_8Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_11Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -189,10 +184,10 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_12Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -205,19 +200,19 @@ export type Endpoint5_14Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_14Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_15Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_15Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_16Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -226,98 +221,97 @@ export type Endpoint5_16Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_16Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type Endpoint5_15Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_17Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_18Input = {
|
||||
export type Endpoint5_17Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_18Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
export type Endpoint5_17Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_20Input = {
|
||||
export type Endpoint5_19Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_20Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_29Input = {
|
||||
export type Endpoint5_28Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_29Output = void
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_30Input,
|
||||
) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_31Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = {
|
||||
export type Endpoint5_31Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_32Output =
|
||||
export type Endpoint5_31Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -905,101 +899,24 @@ export type Endpoint5_32Output =
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.usage.updated"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.input.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.progress"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly metadata: { readonly [x: string]: Schema.Json }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.compaction.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly text: string }
|
||||
}
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_34Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
export type Endpoint5_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_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 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 interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -1008,7 +925,6 @@ export interface SessionApi<E = never> {
|
||||
readonly export: SessionExportOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly snapshot: SessionSnapshotOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
Endpoint0_0Output,
|
||||
Endpoint0_1Input,
|
||||
Endpoint0_1Output,
|
||||
Endpoint1_0Output,
|
||||
Endpoint2_0Input,
|
||||
Endpoint2_0Output,
|
||||
@@ -86,8 +88,6 @@ import type {
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint5_36Input,
|
||||
Endpoint5_36Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -248,7 +248,12 @@ const preserveStream =
|
||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||
preserveEffect<Endpoint0_0Output>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
||||
preserveEffect<Endpoint0_1Output>()(
|
||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
||||
|
||||
const Endpoint1_0 = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<Endpoint1_0Output>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
@@ -352,56 +357,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
||||
|
||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||
preserveEffect<Endpoint5_6Output>()(
|
||||
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||
preserveEffect<Endpoint5_7Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -420,8 +417,8 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -442,16 +439,16 @@ const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -468,16 +465,16 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.compact"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], delivery: input["delivery"] },
|
||||
@@ -487,13 +484,13 @@ const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -503,19 +500,27 @@ const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -523,70 +528,62 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveStream<Endpoint5_32Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
@@ -594,29 +591,29 @@ const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
|
||||
preserveEffect<Endpoint5_36Output>()(
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
raw["session.environment"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { variables: input["variables"] },
|
||||
@@ -630,30 +627,29 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
export: Endpoint5_3(raw),
|
||||
active: Endpoint5_4(raw),
|
||||
get: Endpoint5_5(raw),
|
||||
snapshot: Endpoint5_6(raw),
|
||||
remove: Endpoint5_7(raw),
|
||||
fork: Endpoint5_8(raw),
|
||||
switchAgent: Endpoint5_9(raw),
|
||||
switchModel: Endpoint5_10(raw),
|
||||
rename: Endpoint5_11(raw),
|
||||
move: Endpoint5_12(raw),
|
||||
prompt: Endpoint5_13(raw),
|
||||
command: Endpoint5_14(raw),
|
||||
skill: Endpoint5_15(raw),
|
||||
synthetic: Endpoint5_16(raw),
|
||||
shell: Endpoint5_17(raw),
|
||||
compact: Endpoint5_18(raw),
|
||||
wait: Endpoint5_19(raw),
|
||||
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
|
||||
context: Endpoint5_23(raw),
|
||||
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
|
||||
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
|
||||
generate: Endpoint5_31(raw),
|
||||
log: Endpoint5_32(raw),
|
||||
interrupt: Endpoint5_33(raw),
|
||||
background: Endpoint5_34(raw),
|
||||
message: Endpoint5_35(raw),
|
||||
environment: Endpoint5_36(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),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -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, options.env)
|
||||
return spawnServiceContender(command, args)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -87,7 +87,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
yield* announce("missing")
|
||||
yield* terminate(info, options, timing)
|
||||
yield* evict(info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
|
||||
yield* kill(service, options, timing).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
@@ -133,8 +133,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const info = yield* read(options.file)
|
||||
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, defaultEnsureTiming)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
@@ -243,6 +243,12 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
// discovery window.
|
||||
const poll = (timing: EnsureTiming) =>
|
||||
@@ -263,21 +269,59 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const evict = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isNone(done)) {
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
}
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
timing: EnsureTiming,
|
||||
) {
|
||||
const requested = yield* requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
// immediately before the legacy signal fallback.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
yield* signal(service.info.pid, "SIGKILL")
|
||||
yield* stopped(service.info.pid).pipe(Effect.retry(poll(timing)))
|
||||
})
|
||||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
|
||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
const decoded = decodeStopResponse(body)
|
||||
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted) return "rejected" as const
|
||||
return "accepted" as const
|
||||
})
|
||||
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
HealthStopInput,
|
||||
HealthStopOutput,
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
@@ -20,8 +22,6 @@ import type {
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
SessionSnapshotInput,
|
||||
SessionSnapshotOutput,
|
||||
SessionRemoveInput,
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
@@ -367,6 +367,18 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
stop: (input: HealthStopInput, requestOptions?: RequestOptions) =>
|
||||
request<HealthStopOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/service/stop`,
|
||||
body: { instanceID: input["instanceID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
@@ -516,18 +528,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: SessionSnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionSnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/snapshot`,
|
||||
query: { recent: input["recent"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionRemoveOutput>(
|
||||
{
|
||||
@@ -857,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"], ephemeral: input["ephemeral"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 409, 401, 400],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -2,6 +2,8 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||
|
||||
export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
@@ -10,11 +12,7 @@ export type AgentColor = string
|
||||
|
||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; package: string }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
export type PluginInfo = { id: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
@@ -200,10 +198,6 @@ 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
|
||||
@@ -512,51 +506,6 @@ export type SessionRevertCommitted = {
|
||||
data: { sessionID: string; to: string }
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -602,6 +551,51 @@ export type AgentUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; delta: string }
|
||||
}
|
||||
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.tool.progress"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; id: string; metadata: { [x: string]: JsonValue } }
|
||||
}
|
||||
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
}
|
||||
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1994,15 +1988,7 @@ export type FormCreated = {
|
||||
data: { form: FormInfo1 }
|
||||
}
|
||||
|
||||
export type SessionLogItem =
|
||||
| SessionEventDurable
|
||||
| SessionUsageUpdated
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| EventLogSynced
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
|
||||
|
||||
@@ -2012,16 +1998,6 @@ 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 }
|
||||
@@ -2236,16 +2212,6 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
|
||||
"_tag" in value &&
|
||||
value["_tag"] === "InstructionEntryValueTooLargeError"
|
||||
|
||||
export type SeqUnavailableError = {
|
||||
readonly _tag: "SeqUnavailableError"
|
||||
readonly sessionID: string
|
||||
readonly after: number
|
||||
readonly head?: number | undefined
|
||||
readonly message: string
|
||||
}
|
||||
export const isSeqUnavailableError = (value: unknown): value is SeqUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SeqUnavailableError"
|
||||
|
||||
export type ProviderNotFoundError = {
|
||||
readonly _tag: "ProviderNotFoundError"
|
||||
readonly providerID: string
|
||||
@@ -2307,6 +2273,10 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
||||
|
||||
export type HealthStopOutput = ServiceStopResponse
|
||||
|
||||
export type ServerGetOutput = { urls: Array<string> }
|
||||
|
||||
export type LocationGetInput = {
|
||||
@@ -3318,13 +3288,6 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
|
||||
|
||||
export type SessionGetOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSnapshotInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly recent?: { readonly recent?: number | undefined }["recent"]
|
||||
}
|
||||
|
||||
export type SessionSnapshotOutput = SessionSnapshotResponse["data"]
|
||||
|
||||
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionRemoveOutput = void
|
||||
@@ -3963,21 +3926,8 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
|
||||
|
||||
export type SessionLogInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["after"]
|
||||
readonly follow?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["follow"]
|
||||
readonly ephemeral?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["ephemeral"]
|
||||
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
|
||||
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
|
||||
}
|
||||
|
||||
export type SessionLogOutput = SessionLogItem
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
@@ -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, options.env)
|
||||
return spawnServiceContender(command, args)
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await terminate(registration.info, options, timing)
|
||||
await evict(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await terminate(service.info, options, timing).catch(() => undefined)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
@@ -110,8 +110,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const info = await read(options.file)
|
||||
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options, defaultEnsureTiming)
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -199,6 +199,10 @@ async function registered(file?: string, allowLegacy = false, timeout?: number)
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
@@ -226,19 +230,47 @@ function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
async function evict(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
signal(info.pid, "SIGTERM")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) {
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
if (await waitUntilStopped(info.pid, timing)) return
|
||||
|
||||
const latest = await read(options.file)
|
||||
if (latest === undefined || !same(latest, info)) return
|
||||
await rm(options.file ?? fallback(), { force: true })
|
||||
signal(info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(info.pid, timing))) throw new Error(`Server process ${info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const requested = await requestStop(service, timing.requestTimeout)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (await waitUntilStopped(service.info.pid, timing)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid, timing)))
|
||||
throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
|
||||
@@ -10,16 +10,8 @@ export type ServiceContender = {
|
||||
|
||||
const stderrLimit = 8 * 1024
|
||||
|
||||
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 },
|
||||
})
|
||||
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
|
||||
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
|
||||
let error: Error | undefined
|
||||
let closed = false
|
||||
let stderr = Buffer.alloc(0)
|
||||
|
||||
@@ -28,8 +28,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile, unwrap } from "solid-js/store"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionPromptInput } from "../promise"
|
||||
import { isSeqUnavailableError } from "../promise"
|
||||
import { createData } from "./data"
|
||||
import type { CreateDataInput } from "./data"
|
||||
import { Engine } from "./engine/engine"
|
||||
|
||||
type SessionApi = Pick<OpenCodeClient["session"], "snapshot" | "log" | "prompt">
|
||||
|
||||
// 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>
|
||||
@@ -1,498 +0,0 @@
|
||||
import type {
|
||||
EventLogSynced,
|
||||
SessionCompactionDelta,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionMessageInfo,
|
||||
SessionPromptInput,
|
||||
SessionReasoningDelta,
|
||||
SessionTextDelta,
|
||||
SessionToolInputDelta,
|
||||
SessionToolProgress,
|
||||
SessionUsageUpdated,
|
||||
} from "../../promise"
|
||||
import { SessionFold } from "./fold"
|
||||
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "./fold"
|
||||
|
||||
export type EphemeralSessionEvent =
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| SessionUsageUpdated
|
||||
|
||||
export type SessionStreamItem = DurableSessionEvent | EphemeralSessionEvent | EventLogSynced
|
||||
|
||||
export type Intent = {
|
||||
readonly id: string
|
||||
readonly item: Extract<SessionInboxItem, { readonly type: "user" | "synthetic" }>
|
||||
readonly request: Omit<SessionPromptInput, "sessionID" | "id">
|
||||
readonly created: number
|
||||
}
|
||||
|
||||
export type SubmitInput = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly request: Intent["request"]
|
||||
}
|
||||
|
||||
export type IntentFailure = {
|
||||
readonly intent: Intent
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export class SubmitRejected extends Error {
|
||||
readonly _tag = "SubmitRejected"
|
||||
|
||||
constructor(readonly reason: string) {
|
||||
super(reason)
|
||||
}
|
||||
}
|
||||
|
||||
export class SeqUnavailable extends Error {
|
||||
readonly _tag = "SeqUnavailable"
|
||||
}
|
||||
|
||||
export interface SessionTransport {
|
||||
readonly snapshot: (sessionID: string) => Promise<SessionSnapshot>
|
||||
readonly stream: (sessionID: string, after: number, signal?: AbortSignal) => AsyncIterable<SessionStreamItem>
|
||||
readonly submit: (input: SubmitInput) => Promise<void>
|
||||
}
|
||||
|
||||
export type SessionView = SessionFoldState & {
|
||||
readonly pending: ReadonlyArray<SessionInboxInfo>
|
||||
}
|
||||
|
||||
export interface SessionEngine {
|
||||
readonly sessionID: string
|
||||
readonly view: () => SessionView
|
||||
readonly submit: (input: Intent["request"] & { readonly id?: string }) => Intent
|
||||
readonly subscribe: (listener: (view: SessionView) => void) => () => void
|
||||
readonly subscribeFailures: (listener: (failure: IntentFailure) => void) => () => void
|
||||
readonly ready: () => Promise<void>
|
||||
readonly refresh: () => Promise<void>
|
||||
readonly settled: () => Promise<void>
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
export type SessionEngineOptions = {
|
||||
readonly makeID?: () => string
|
||||
readonly now?: () => number
|
||||
readonly reconnect?: () => Promise<void>
|
||||
}
|
||||
|
||||
type Overlay = ReadonlyMap<string, OverlayEntry>
|
||||
|
||||
type OverlayEntry =
|
||||
| { readonly type: "text"; readonly value: string }
|
||||
| { readonly type: "reasoning"; readonly value: string }
|
||||
| { readonly type: "tool-input"; readonly value: string }
|
||||
| { readonly type: "tool-progress"; readonly metadata: SessionToolProgress["data"]["metadata"] }
|
||||
| { readonly type: "compaction"; readonly value: string }
|
||||
| { readonly type: "usage"; readonly value: SessionUsageUpdated["data"] }
|
||||
|
||||
type EngineState = {
|
||||
readonly folded: SessionFoldState
|
||||
readonly outbox: ReadonlyArray<Intent>
|
||||
readonly overlay: Overlay
|
||||
readonly synced: boolean
|
||||
}
|
||||
|
||||
export async function createSessionEngine(
|
||||
sessionID: string,
|
||||
transport: SessionTransport,
|
||||
options: SessionEngineOptions = {},
|
||||
): Promise<SessionEngine> {
|
||||
let counter = 0
|
||||
const makeID = options.makeID ?? (() => `msg_${Date.now().toString(36)}_${++counter}`)
|
||||
const now = options.now ?? Date.now
|
||||
const reconnect = options.reconnect ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
|
||||
let state: EngineState = {
|
||||
folded: SessionFold.fromSnapshot(await transport.snapshot(sessionID)),
|
||||
outbox: [],
|
||||
overlay: new Map(),
|
||||
synced: false,
|
||||
}
|
||||
const listeners = new Set<(view: SessionView) => void>()
|
||||
const failureListeners = new Set<(failure: IntentFailure) => void>()
|
||||
const settled = new Set<() => void>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let sent: string | undefined
|
||||
let stopped = false
|
||||
let sending = false
|
||||
let refreshing: Promise<void> | undefined
|
||||
const abort = new AbortController()
|
||||
|
||||
const publish = (next: EngineState) => {
|
||||
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"
|
||||
@@ -1,580 +0,0 @@
|
||||
import type {
|
||||
SessionEventDurable,
|
||||
SessionInboxInfo,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
TokenUsageInfo,
|
||||
} from "../../promise"
|
||||
|
||||
export type SessionFoldState = {
|
||||
readonly session: SessionInfo
|
||||
readonly children: ReadonlyArray<SessionInfo>
|
||||
readonly inbox: ReadonlyArray<SessionInboxInfo>
|
||||
readonly messages: ReadonlyArray<SessionMessageInfo>
|
||||
readonly active: "idle" | "running"
|
||||
readonly deleted: boolean
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
export type SessionSnapshot = Omit<SessionFoldState, "active" | "deleted"> & {
|
||||
readonly active?: SessionFoldState["active"]
|
||||
}
|
||||
|
||||
export type DurableSessionEvent = Exclude<SessionEventDurable, { readonly type: "session.forked" }>
|
||||
|
||||
export function fromSnapshot(snapshot: SessionSnapshot): SessionFoldState {
|
||||
return { ...snapshot, active: snapshot.active ?? "idle", deleted: false }
|
||||
}
|
||||
|
||||
export function apply(state: SessionFoldState, event: DurableSessionEvent): SessionFoldState {
|
||||
if (event.durable.seq <= state.seq) return state
|
||||
const current = { ...state, seq: event.durable.seq }
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
return current
|
||||
case "session.deleted":
|
||||
return { ...current, deleted: true }
|
||||
case "session.usage.recorded":
|
||||
return { ...current, session: addUsage(state.session, event.data.cost, event.data.tokens, event.created) }
|
||||
case "session.agent.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
agent: event.data.agent,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous: event.data.previous ?? state.session.agent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.model.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
model: event.data.model,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
previous: event.data.previous ?? state.session.model,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.moved":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous: {
|
||||
location: state.session.location,
|
||||
projectID: state.session.projectID,
|
||||
subpath: state.session.subpath,
|
||||
},
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.renamed":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, title: event.data.title, time: { ...state.session.time, updated: event.created } },
|
||||
}
|
||||
case "session.inbox.enqueued":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, time: { ...state.session.time, updated: event.created } },
|
||||
inbox: state.inbox.some((item) => item.id === event.data.inboxID)
|
||||
? state.inbox
|
||||
: [
|
||||
...state.inbox,
|
||||
{
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
},
|
||||
],
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const item = state.inbox.find((item) => item.id === event.data.inboxID)
|
||||
const next = { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
if (!item) return next
|
||||
const delivered = messageFromInbox(item, event.created)
|
||||
return delivered ? append(next, delivered) : next
|
||||
}
|
||||
case "session.inbox.cancelled":
|
||||
return { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
case "session.inbox.delivery.changed":
|
||||
return {
|
||||
...current,
|
||||
inbox: state.inbox.map((item) =>
|
||||
item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item,
|
||||
),
|
||||
}
|
||||
case "session.execution.started":
|
||||
return { ...current, active: "running" }
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
return { ...updateActiveAssistant(current, (message) => ({ ...message, retry: undefined })), active: "idle" }
|
||||
case "session.instructions.updated":
|
||||
if (event.data.text === undefined) return current
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.type === "shell" && message.shellID === event.data.shell.id,
|
||||
(message) => {
|
||||
if (message.type !== "shell") return message
|
||||
return {
|
||||
...message,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...message.time, completed: event.created },
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const existing = state.messages.some((message) => message.id === event.data.assistantMessageID)
|
||||
if (existing)
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
time: { ...message.time, completed: undefined },
|
||||
snapshot: event.data.snapshot ? { ...message.snapshot, start: event.data.snapshot } : message.snapshot,
|
||||
}))
|
||||
return append(
|
||||
updateActiveAssistant(current, (message) => ({
|
||||
...message,
|
||||
retry: undefined,
|
||||
time: { ...message.time, completed: event.created },
|
||||
})),
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return withUsage(
|
||||
updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
})),
|
||||
event.data.cost,
|
||||
event.data.tokens,
|
||||
event.created,
|
||||
)
|
||||
case "session.step.failed": {
|
||||
const failed = updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? message.cost,
|
||||
tokens: event.data.tokens ?? message.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
}))
|
||||
if (event.data.cost === undefined || event.data.tokens === undefined) return failed
|
||||
return withUsage(failed, event.data.cost, event.data.tokens, event.created)
|
||||
}
|
||||
case "session.text.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "text", event.data.ordinal, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
state: event.data.state,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "reasoning", event.data.ordinal, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? part.state,
|
||||
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||
}))
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: [
|
||||
...message.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) =>
|
||||
part.state.status === "streaming" ? { ...part, state: { ...part.state, input: event.data.text } } : part,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => ({
|
||||
...part,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, metadata: {} },
|
||||
time: { ...part.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.success":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "streaming" && part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.compaction.started":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
},
|
||||
{
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
if (!running)
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.id === running.id,
|
||||
(message) => ({
|
||||
...message,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
const failed = {
|
||||
id: running?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction" as const,
|
||||
status: "failed" as const,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
metadata: running?.metadata ?? event.metadata,
|
||||
time: running?.time ?? { created: event.created },
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
}
|
||||
return running
|
||||
? updateMessage(
|
||||
next,
|
||||
(message) => message.id === running.id,
|
||||
() => failed,
|
||||
)
|
||||
: append(next, failed)
|
||||
}
|
||||
case "session.revert.staged":
|
||||
return {
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
revert: event.data.revert,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
}
|
||||
case "session.revert.cleared":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
|
||||
}
|
||||
case "session.revert.committed":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
|
||||
messages: state.messages.filter((message) => message.id < event.data.to),
|
||||
inbox: state.inbox.filter((item) => item.id < event.data.to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
export function messageFromInbox(item: SessionInboxInfo, created = item.timeCreated): SessionMessageInfo | undefined {
|
||||
if (item.type === "user") return { id: item.id, type: "user", ...item.payload, time: { created } }
|
||||
if (item.type === "synthetic") return { id: item.id, type: "synthetic", ...item.payload, time: { created } }
|
||||
}
|
||||
|
||||
function append(state: SessionFoldState, item: SessionMessageInfo) {
|
||||
if (state.messages.some((message) => message.id === item.id)) return state
|
||||
return { ...state, messages: [...state.messages, item] }
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
state: SessionFoldState,
|
||||
predicate: (message: SessionMessageInfo) => boolean,
|
||||
update: (message: SessionMessageInfo) => SessionMessageInfo,
|
||||
last = false,
|
||||
) {
|
||||
const index = last ? state.messages.findLastIndex(predicate) : state.messages.findIndex(predicate)
|
||||
if (index < 0) return state
|
||||
return {
|
||||
...state,
|
||||
messages: state.messages.map((message, position) => (position === index ? update(message) : message)),
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.id === messageID && message.type === "assistant",
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
)
|
||||
}
|
||||
|
||||
function updateActiveAssistant(
|
||||
state: SessionFoldState,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function updateContent<Type extends "text" | "reasoning">(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
update: (
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) => Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const position = message.content.flatMap((part, index) => (part.type === type ? [index] : []))[ordinal]
|
||||
const part = position === undefined ? undefined : message.content[position]
|
||||
if (!part || part.type !== type) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item, index) =>
|
||||
index === position
|
||||
? update(part as Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>)
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
toolID: string,
|
||||
update: (part: SessionMessageAssistantTool) => SessionMessageAssistantTool,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const index = message.content.findLastIndex((part) => part.type === "tool" && part.id === toolID)
|
||||
if (index < 0) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part, position) =>
|
||||
position === index && part.type === "tool" ? update(part) : part,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertOrdinal<Type extends SessionMessageAssistant["content"][number]["type"]>(
|
||||
content: SessionMessageAssistant["content"],
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
if (content.filter((item) => item.type === type)[ordinal]) return content
|
||||
return [...content, part]
|
||||
}
|
||||
|
||||
function addUsage(session: SessionInfo, cost: number, tokens: TokenUsageInfo, updated: number): SessionInfo {
|
||||
return {
|
||||
...session,
|
||||
cost: session.cost + cost,
|
||||
tokens: {
|
||||
input: session.tokens.input + tokens.input,
|
||||
output: session.tokens.output + tokens.output,
|
||||
reasoning: session.tokens.reasoning + tokens.reasoning,
|
||||
cache: {
|
||||
read: session.tokens.cache.read + tokens.cache.read,
|
||||
write: session.tokens.cache.write + tokens.cache.write,
|
||||
},
|
||||
},
|
||||
time: { ...session.time, updated },
|
||||
}
|
||||
}
|
||||
|
||||
function withUsage(state: SessionFoldState, cost: number, tokens: TokenUsageInfo, updated: number) {
|
||||
return { ...state, session: addUsage(state.session, cost, tokens, updated) }
|
||||
}
|
||||
|
||||
export * as SessionFold from "./fold"
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./engine-data"
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -11,8 +11,6 @@ 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") {
|
||||
@@ -30,7 +28,7 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
|
||||
|
||||
let requests = 0
|
||||
let version = "test"
|
||||
if (mode === "old") version = "old"
|
||||
if (mode === "old" || mode === "reject-stop") version = "old"
|
||||
if (mode === "incompatible") version = "1.9.0"
|
||||
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
|
||||
const id = crypto.randomUUID()
|
||||
@@ -38,6 +36,17 @@ const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
if (pathname === "/api/service/stop" && mode === "reject-stop") {
|
||||
await appendFile(registration + ".stop-attempts", process.pid + "\n")
|
||||
return Response.json({ accepted: false })
|
||||
}
|
||||
if (pathname === "/api/service/stop" && mode === "graceful") {
|
||||
const body = await request.json()
|
||||
if (typeof body !== "object" || body === null || body.instanceID !== id) return Response.json({ accepted: false })
|
||||
await writeFile(registration + ".stop", JSON.stringify(body))
|
||||
setTimeout(shutdown, 25)
|
||||
return Response.json({ accepted: true })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
@@ -54,7 +63,7 @@ const server = Bun.serve({
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful")
|
||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
@@ -72,10 +81,9 @@ await writeFile(
|
||||
)
|
||||
await rename(registration + ".tmp", registration)
|
||||
|
||||
async function shutdown(signal?: NodeJS.Signals) {
|
||||
if (signal !== undefined) await writeFile(registration + ".signal", signal)
|
||||
function shutdown() {
|
||||
server.stop(true)
|
||||
process.exit()
|
||||
}
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
process.on("SIGTERM", shutdown)
|
||||
process.on("SIGINT", shutdown)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
// 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 }))
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// 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,26 +59,6 @@ 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")
|
||||
@@ -146,13 +126,13 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("signals the registered service process", async () => {
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
|
||||
@@ -191,6 +191,22 @@ test("integration connections optionally submit a form answer", async () => {
|
||||
expect(await requests[3].json()).toEqual({ methodID: "device" })
|
||||
})
|
||||
|
||||
test("health.stop sends exact replacement identity", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({ accepted: true })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
||||
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
||||
})
|
||||
|
||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -68,28 +68,6 @@ 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")
|
||||
@@ -165,36 +143,40 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
await waitForExit(replacement.pid)
|
||||
})
|
||||
|
||||
test("signals an unresponsive registered service process", async () => {
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const process = spawn(registration, "hanging")
|
||||
const process = spawn(registration, "graceful")
|
||||
await waitForFile(registration)
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await run(Service.stop({ file: registration }))
|
||||
await process.exited
|
||||
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
test("signals an incompatible service before starting its replacement", async () => {
|
||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const existing = spawn(registration, "old")
|
||||
const contender = join(directory, "contender.json")
|
||||
const existing = spawn(registration, "reject-stop")
|
||||
await waitForFile(registration)
|
||||
const endpoint = await run(
|
||||
const controller = new AbortController()
|
||||
const starting = Effect.runPromise(
|
||||
ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "delayed", "10"],
|
||||
}),
|
||||
command: [process.execPath, fixture, contender, "record-start"],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
const replacement = await Bun.file(registration).json()
|
||||
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
process.kill(replacement.pid, "SIGTERM")
|
||||
await waitForExit(replacement.pid)
|
||||
await waitForLines(registration + ".stop-attempts", 2)
|
||||
controller.abort()
|
||||
await starting.catch(() => undefined)
|
||||
|
||||
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||
expect(existing.exitCode).toBe(null)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
@@ -362,6 +344,17 @@ async function waitForFile(file: string) {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForLines(file: string, count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (text.trim().split("\n").length >= count) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} lines in ${file}`)
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
]
|
||||
}
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
get: Effect.fn("Agent.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
resolve: Effect.fnUntraced(function* (id) {
|
||||
resolve: Effect.fn("Agent.resolve")(function* (id) {
|
||||
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
||||
return selectedDefault()
|
||||
}),
|
||||
|
||||
@@ -44,21 +44,6 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const retainedCount = Effect.fn("Bus.retainedCount")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
after: number,
|
||||
through: number,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.count ?? 0
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: Event.ID
|
||||
readonly type: string
|
||||
@@ -165,7 +150,6 @@ export interface Interface {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
@@ -784,7 +768,6 @@ export function configured(options?: Options) {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}): Stream.Stream<LogItem> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -808,8 +791,7 @@ export function configured(options?: Options) {
|
||||
)
|
||||
// Subscribing before the historical read means events committed during
|
||||
// replay either appear in the read or arrive through a post-marker wake.
|
||||
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
|
||||
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const target = yield* latestSequence(db, input.aggregateID)
|
||||
const marker: EventLog.Synced = {
|
||||
type: "log.synced",
|
||||
@@ -820,14 +802,6 @@ export function configured(options?: Options) {
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
if (subscription && input.includeLive) {
|
||||
const follow: Stream.Stream<LogItem> = Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter(input.includeLive),
|
||||
Stream.filter((event) => !event.durable || event.durable.seq > target),
|
||||
Stream.map((event): LogItem => event),
|
||||
)
|
||||
return Stream.concat(replay, follow)
|
||||
}
|
||||
if (!wakes) return replay
|
||||
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
|
||||
|
||||
@@ -426,7 +426,7 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
return configs
|
||||
}),
|
||||
update,
|
||||
|
||||
@@ -149,7 +149,7 @@ export function normalize(input: unknown): Result {
|
||||
"agents",
|
||||
migratedAgents,
|
||||
nativeAgents,
|
||||
migratedSmallModel !== undefined || isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
export * as ConfigFormatterPlugin from "./formatter.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
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 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) 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 builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const current: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
draft.set(current)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
export * as ConfigImagePlugin from "./image.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Image } from "../../image.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.image",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
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") 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 }),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -120,13 +120,9 @@ 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.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))
|
||||
if (method === "values") return statement.values
|
||||
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
|
||||
return statement.withoutTransform
|
||||
}
|
||||
|
||||
private isInTransaction() {
|
||||
|
||||
@@ -4,21 +4,15 @@ 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 { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import type { Info } from "./formatter/builtins.js"
|
||||
import { State } from "./state.js"
|
||||
import { make, type Info } from "./formatter/builtins.js"
|
||||
|
||||
type Data = {
|
||||
formatters: Info[]
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
set: (formatter: Info) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -27,36 +21,66 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const commands = new WeakMap<Info, string[] | false>()
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "formatter",
|
||||
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)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const global = yield* Global.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter)
|
||||
const cached = commands.get(formatter.name)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter, result)
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
const matching = state
|
||||
.get()
|
||||
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
@@ -94,12 +118,12 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ transform: state.transform, reload: state.reload, file })
|
||||
return Service.of({ file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Location.node, AppProcess.node],
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
})
|
||||
|
||||
+17
-33
@@ -2,8 +2,8 @@ export * as Image from "./image.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedError<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
@@ -32,18 +32,7 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
||||
}
|
||||
}
|
||||
|
||||
export type Limits = {
|
||||
autoResize: boolean
|
||||
maxWidth: number
|
||||
maxHeight: number
|
||||
maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
configure: (limits: Partial<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
export interface Interface {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -58,23 +47,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Limits, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
maxWidth: 2_000,
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
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 config = yield* Config.Service
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -85,11 +58,22 @@ const layer = Layer.effect(
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
) {
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, state.get())
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
return Service.of({ transform: state.transform, reload: state.reload, normalize })
|
||||
return Service.of({ normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
||||
@@ -138,7 +138,7 @@ export const make = Effect.gen(function* () {
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const settle = Effect.fn("Job.settle")(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.fnUntraced(function* (
|
||||
const fork = Effect.fn("Job.fork")(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.fnUntraced(function* (input) {
|
||||
const start: Interface["start"] = Effect.fn("Job.start")(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.fnUntraced(function* (input: BlockInput) {
|
||||
const removeBlock = Effect.fn("Job.removeBlock")(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.fnUntraced(function* (input) {
|
||||
const block: Interface["block"] = Effect.fn("Job.block")(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]
|
||||
|
||||
@@ -65,7 +65,7 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
if (FSUtil.contains(location.directory, absolute)) {
|
||||
return {
|
||||
|
||||
@@ -152,14 +152,14 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
|
||||
const configured = Effect.fn("Permission.configured")(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.fnUntraced(function* (input: {
|
||||
const allowsAll = Effect.fn("Permission.allowsAll")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly action: string
|
||||
readonly agent?: Agent.ID
|
||||
|
||||
@@ -39,7 +39,7 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const list = Effect.fnUntraced(function* (input?: ListInput) {
|
||||
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(PermissionTable)
|
||||
|
||||
+13
-47
@@ -1,10 +1,10 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info } 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 { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { 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"
|
||||
@@ -23,17 +23,11 @@ import { Tool } from "./tool.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly activate: (
|
||||
plugins: readonly Versioned[],
|
||||
failures?: readonly Extract<Plugin.Info, { readonly status: "failed" }>[],
|
||||
) => Effect.Effect<void>
|
||||
readonly activate: (plugins: readonly Versioned[]) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
}
|
||||
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & {
|
||||
readonly version: string
|
||||
readonly source?: Plugin.Source
|
||||
}
|
||||
export type Versioned = import("@opencode-ai/plugin/effect/plugin").Plugin & { readonly version: string }
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
@@ -44,7 +38,6 @@ 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) {
|
||||
@@ -63,18 +56,15 @@ const layer = Layer.effect(
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(loaded)) return { scope: child } as const
|
||||
if (Exit.isSuccess(loaded)) return child
|
||||
yield* Effect.logWarning("failed to load plugin", {
|
||||
"plugin.id": plugin.id,
|
||||
cause: loaded.cause,
|
||||
})
|
||||
return { error: Cause.pretty(loaded.cause) } as const
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activate = Effect.fn("Plugin.activate")(function* (
|
||||
plugins: readonly Versioned[],
|
||||
failures: readonly Extract<Plugin.Info, { readonly status: "failed" }>[] = [],
|
||||
) {
|
||||
const activate = Effect.fn("Plugin.activate")(function* (plugins: readonly Versioned[]) {
|
||||
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
|
||||
const ids = new Set<Plugin.ID>()
|
||||
for (const definition of definitions) {
|
||||
@@ -95,40 +85,26 @@ 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.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
if (loaded) {
|
||||
active.set(definition.id, { plugin: definition, scope: loaded })
|
||||
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.scope !== undefined) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
if (restored) {
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -143,7 +119,6 @@ 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, {})
|
||||
@@ -161,7 +136,7 @@ const layer = Layer.effect(
|
||||
const service = Service.of({
|
||||
activate,
|
||||
list: Effect.fn("Plugin.list")(function* () {
|
||||
return inventory
|
||||
return Array.from(active.keys()).map((id) => ({ id }))
|
||||
}),
|
||||
})
|
||||
host = yield* PluginHost.make(service)
|
||||
@@ -169,15 +144,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -27,26 +26,16 @@ 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,
|
||||
@@ -60,47 +49,36 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const callbacks = new Map<string, Entry[]>()
|
||||
const callbacks = new Map<string, Function[]>()
|
||||
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
|
||||
|
||||
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 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 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],
|
||||
)
|
||||
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,
|
||||
])
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
const has: Interface["has"] = (domain, name, providerID) =>
|
||||
Effect.sync(() =>
|
||||
(callbacks.get(key(domain, name)) ?? []).some(
|
||||
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
|
||||
),
|
||||
)
|
||||
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
|
||||
|
||||
return Service.of({ has, register, trigger })
|
||||
}),
|
||||
|
||||
@@ -104,10 +104,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback, options) => {
|
||||
hook: (name, callback) => {
|
||||
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,
|
||||
@@ -120,7 +119,6 @@ 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,
|
||||
@@ -384,7 +382,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -13,8 +12,6 @@ import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
@@ -80,7 +77,6 @@ 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
|
||||
@@ -119,7 +115,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
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),
|
||||
@@ -165,7 +160,6 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
AppProcess.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
@@ -238,8 +232,6 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Effect } from "effect"
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode.exclusion",
|
||||
id: "opencode.mcp.codemode-exclusion",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.mcp.transform((draft) => {
|
||||
for (const [, server] of draft.list()) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models.dev",
|
||||
id: "opencode.models-dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -55,13 +55,8 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
|
||||
function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
if (provider.info.id === Provider.ID.azure)
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
// models.dev advertises project, location, and the ADC credentials file path for
|
||||
// Vertex. Those configure Google auth rather than carrying a key, so only the
|
||||
// Express Mode key may become a credential; GoogleVertexPlugin handles activation.
|
||||
if (provider.info.id === Provider.ID.googleVertex) return ["GOOGLE_VERTEX_API_KEY"]
|
||||
return [...provider.environment]
|
||||
if (provider.info.id !== Provider.ID.azure) return [...provider.environment]
|
||||
return [...provider.environment.filter((name) => name.endsWith("_API_KEY")), "AZURE_COGNITIVE_SERVICES_API_KEY"]
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
|
||||
@@ -60,7 +60,7 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
id: "opencode.provider.amazon-bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare.ai.gateway",
|
||||
id: "opencode.provider.cloudflare-ai-gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { configuredSettings } from "./configured.js"
|
||||
const providerID = Provider.ID.make("cloudflare-workers-ai")
|
||||
|
||||
export const CloudflareWorkersAIPlugin = define({
|
||||
id: "opencode.provider.cloudflare.workers.ai",
|
||||
id: "opencode.provider.cloudflare-workers-ai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const form = iife(() => {
|
||||
|
||||
@@ -146,7 +146,7 @@ const oauth = (app: App.Info) =>
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
id: "opencode.provider.github.copilot",
|
||||
id: "opencode.provider.github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -241,22 +241,19 @@ 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)
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
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.aisdk.hook(
|
||||
"language",
|
||||
|
||||
@@ -55,7 +55,7 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
|
||||
}
|
||||
|
||||
export const GoogleVertexPlugin = define({
|
||||
id: "opencode.provider.google.vertex",
|
||||
id: "opencode.provider.google-vertex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
@@ -71,9 +71,6 @@ export const GoogleVertexPlugin = define({
|
||||
const project = resolveProject(item.provider.settings ?? {})
|
||||
const location = String(resolveLocation(item.provider.settings ?? {}))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
// Vertex authenticates through ADC rather than a key credential, so a
|
||||
// resolvable project is what makes the provider usable.
|
||||
if (project && provider.activation === "auto") provider.activation = "enabled"
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(project ? { project } : {}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const OpenAICompatiblePlugin = define({
|
||||
id: "opencode.provider.openai.compatible",
|
||||
id: "opencode.provider.openai-compatible",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -229,17 +230,15 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
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 },
|
||||
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)
|
||||
}),
|
||||
)
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Provider } from "../../provider.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
id: "opencode.provider.sap-ai-core",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const npm = yield* Npm.Service
|
||||
yield* ctx.aisdk.hook(
|
||||
|
||||
@@ -65,7 +65,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
|
||||
}
|
||||
|
||||
export const SnowflakeCortexPlugin = define({
|
||||
id: "opencode.provider.snowflake.cortex",
|
||||
id: "opencode.provider.snowflake-cortex",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
|
||||
@@ -35,7 +35,7 @@ export const layer = Layer.effect(
|
||||
return Service.of({
|
||||
register: (plugin) =>
|
||||
Effect.sync(() => {
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
|
||||
plugins.set(plugin.id, { ...plugin, version: String(++revision) })
|
||||
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
|
||||
all: () => [...plugins.values()],
|
||||
})
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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") {}
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -15,20 +14,17 @@ 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",
|
||||
),
|
||||
@@ -46,12 +42,10 @@ 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))
|
||||
@@ -71,35 +65,21 @@ 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({ error: Cause.pretty(cause) }),
|
||||
),
|
||||
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if ("error" in plugin) {
|
||||
failures.set(operation.target, {
|
||||
source: pluginSource(operation.target),
|
||||
status: "failed",
|
||||
error: plugin.error,
|
||||
tui: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
failures.delete(operation.target)
|
||||
if (!plugin) continue
|
||||
const previous = packages.get(operation.target)
|
||||
if (previous) enabled.delete(previous.id)
|
||||
packages.set(operation.target, plugin)
|
||||
enabled.add(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()],
|
||||
}
|
||||
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)),
|
||||
]
|
||||
})
|
||||
|
||||
const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
@@ -109,7 +89,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 yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
if (!entrypoint) return
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
@@ -123,13 +103,18 @@ 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* () {
|
||||
@@ -144,20 +129,13 @@ 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", source: { type: "builtin" as const } })),
|
||||
...sdk.all(),
|
||||
]
|
||||
const post = internal.post.map((plugin) => ({
|
||||
...plugin,
|
||||
version: "internal",
|
||||
source: { type: "builtin" as const },
|
||||
}))
|
||||
const pre = [...internal.pre.map((plugin) => ({ ...plugin, version: "internal" })), ...sdk.all()]
|
||||
const post = internal.post.map((plugin) => ({ ...plugin, version: "internal" }))
|
||||
const operations = yield* sources.operations()
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
const plugins = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
yield* registry.activate(plugins)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
@@ -194,9 +172,4 @@ 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 })
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin,
|
||||
|
||||
function make(id: string, select: (modelID: string) => string | undefined) {
|
||||
return define({
|
||||
id: `opencode.prompt.${id}`,
|
||||
id: `opencode.system-prompt.${id}`,
|
||||
effect: Effect.fn(`SystemPromptPlugin.${id}`)(function* (ctx) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -48,17 +48,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
["@opencode-ai/ai/providers/google-vertex/chat", () => import("@opencode-ai/ai/providers/google-vertex/chat")],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
],
|
||||
[
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
+16
-118
@@ -40,7 +40,6 @@ 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"
|
||||
@@ -141,11 +140,6 @@ 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,
|
||||
@@ -199,33 +193,13 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
|
||||
readonly snapshot: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
recent?: number
|
||||
}) => Effect.Effect<
|
||||
{
|
||||
readonly session: SessionSchema.Info
|
||||
readonly children: SessionSchema.Info[]
|
||||
readonly inbox: SessionInbox.Info[]
|
||||
readonly messages: SessionMessage.Info[]
|
||||
readonly seq: Event.Seq
|
||||
},
|
||||
NotFoundError | MessageDecodeError
|
||||
>
|
||||
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly openLog: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
ephemeral?: boolean
|
||||
}) => Effect.Effect<Stream.Stream<SessionEvent.Event | EventLog.Synced>, NotFoundError | SeqUnavailableError>
|
||||
/**
|
||||
* Ordered session log read. Replays durable session events after the
|
||||
* exclusive `after` cursor, emits a `Synced` marker at the captured replay
|
||||
* watermark, then continues live when `follow` is set. Ephemeral events are
|
||||
* included only in the live phase when explicitly requested.
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
* replay watermark, then continues live when `follow` is set.
|
||||
* The marker's seq may exceed the last emitted event because other durable
|
||||
* bus share the aggregate's sequence space.
|
||||
*/
|
||||
@@ -233,8 +207,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
ephemeral?: boolean
|
||||
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
@@ -352,7 +325,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const isSessionEvent = Schema.is(SessionEvent.All)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
@@ -584,90 +556,20 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionInbox.list(db, sessionID)
|
||||
}),
|
||||
snapshot: Effect.fn("Session.snapshot")(function* (input) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
const children = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.parent_id, input.sessionID))
|
||||
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inbox = yield* SessionInbox.list(db, input.sessionID)
|
||||
const messages = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, input.sessionID))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(input.recent ?? 200)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const seq = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (seq < 0) return yield* Effect.die(new Error(`Session ${input.sessionID} has no event sequence`))
|
||||
return {
|
||||
session: fromRow(row),
|
||||
children: children.map(fromRow),
|
||||
inbox,
|
||||
messages: yield* Effect.forEach(messages.toReversed(), decode),
|
||||
seq: Event.Seq.make(seq),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchTag("SqlError", Effect.die))
|
||||
}),
|
||||
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
|
||||
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
|
||||
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
|
||||
openLog: Effect.fn("Session.openLog")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
if (input.after !== undefined) {
|
||||
const head = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (input.after > head)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: head >= 0 ? Event.Seq.make(head) : undefined,
|
||||
})
|
||||
// A cursor claims the caller already holds everything through `after`, so
|
||||
// replay of (after, head] must be provably complete. Without retained rows
|
||||
// covering the range (events.persist off, or pruned history) replaying
|
||||
// nothing would silently desync the caller; fail so it re-snapshots instead.
|
||||
if (input.after < head) {
|
||||
const retained = yield* Bus.retainedCount(db, input.sessionID, input.after, head)
|
||||
if (retained < head - input.after)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: Event.Seq.make(head),
|
||||
})
|
||||
}
|
||||
}
|
||||
return bus
|
||||
.log({
|
||||
aggregateID: input.sessionID,
|
||||
after: input.after,
|
||||
follow: input.follow,
|
||||
includeLive: input.ephemeral
|
||||
? (event) => isSessionEvent(event) && event.data.sessionID === input.sessionID
|
||||
: undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.Event | EventLog.Synced =>
|
||||
Bus.isSynced(item) || (input.ephemeral ? isSessionEvent(item) : isDurableSessionEvent(item)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
log: (input) => Stream.unwrap(result.openLog(input)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(bus.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
|
||||
).pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.DurableEvent | EventLog.Synced =>
|
||||
Bus.isSynced(item) || isDurableSessionEvent(item),
|
||||
),
|
||||
),
|
||||
prompt: Effect.fn("Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
@@ -677,11 +579,7 @@ 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 = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const image = 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 },
|
||||
|
||||
@@ -12,7 +12,6 @@ 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"
|
||||
@@ -271,25 +270,23 @@ 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(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
.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: [],
|
||||
}),
|
||||
})
|
||||
{
|
||||
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,7 +11,6 @@ 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"
|
||||
@@ -72,9 +71,7 @@ export const layer = Layer.effect(
|
||||
providerID: model.ref.providerID,
|
||||
modelID: model.ref.id,
|
||||
})
|
||||
const request = yield* SessionModelHook.apply(
|
||||
hooks,
|
||||
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
|
||||
const response = yield* llm.generate(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
@@ -83,14 +80,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
|
||||
}),
|
||||
|
||||
@@ -180,27 +180,13 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
|
||||
) {
|
||||
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 }))
|
||||
}),
|
||||
)
|
||||
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* (
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -227,25 +226,19 @@ export const layer = Layer.effect(
|
||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||
}),
|
||||
)
|
||||
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 request = LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
promptCacheKey: SessionPromptCacheKey.make(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", resolved.ref.providerID)) &&
|
||||
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
const http = webSocketEligible
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
@@ -258,7 +251,7 @@ export const layer = Layer.effect(
|
||||
...(webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
request.model.route.id === "openai-responses"
|
||||
model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ const layer = Layer.effect(
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
return Service.of({
|
||||
get: Effect.fnUntraced(function* (sessionID) {
|
||||
get: Effect.fn("SessionStore.get")(function* (sessionID) {
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -81,25 +80,23 @@ 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(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
}),
|
||||
})
|
||||
{
|
||||
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
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "../model.js"
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
|
||||
|
||||
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
||||
input: safe(usage?.nonCachedInputTokens),
|
||||
@@ -27,10 +26,10 @@ export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info)
|
||||
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
|
||||
if (!cost) return Money.USD.zero
|
||||
return Money.USD.make(
|
||||
(usage.input * finite(cost.input) +
|
||||
(usage.output + usage.reasoning) * finite(cost.output) +
|
||||
usage.cache.read * finite(cost.cache.read) +
|
||||
usage.cache.write * finite(cost.cache.write)) /
|
||||
(usage.input * cost.input +
|
||||
(usage.output + usage.reasoning) * cost.output +
|
||||
usage.cache.read * cost.cache.read +
|
||||
usage.cache.write * cost.cache.write) /
|
||||
1_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export const layer = (options?: ShellSelect.Options) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const require = Effect.fn("Shell.require")(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.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
|
||||
@@ -153,7 +153,7 @@ const ARITY: Record<string, number> = {
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fnUntraced(function* (
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (
|
||||
command: string,
|
||||
shell: string,
|
||||
cwd: string,
|
||||
@@ -163,7 +163,7 @@ export const scan = Effect.fnUntraced(function* (
|
||||
return yield* scanLegacy(command, shell, cwd)
|
||||
})
|
||||
|
||||
const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
|
||||
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(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)
|
||||
|
||||
@@ -97,7 +97,10 @@ 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)
|
||||
for (const transform of transforms)
|
||||
yield* apply(transform.run, api).pipe(
|
||||
Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }),
|
||||
)
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.data, DIRECTORY)
|
||||
|
||||
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||
const truncate = Effect.fn("ToolOutput.truncate")(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 ?? [])
|
||||
|
||||
@@ -50,7 +50,7 @@ const layer = Layer.effect(
|
||||
const image = yield* Image.Service
|
||||
|
||||
type NormalizedItem = Tool.Content | "decode" | "size"
|
||||
const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray<Tool.Content>) {
|
||||
const normalizeImages = Effect.fn("Tool.normalizeImages")(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]
|
||||
|
||||
@@ -208,7 +208,7 @@ export const Plugin = {
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fnUntraced(function* () {
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(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.fnUntraced(function* () {
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -29,6 +30,7 @@ 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
|
||||
@@ -95,7 +97,9 @@ export const Plugin = {
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* websearch.select(false)
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = false
|
||||
})
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
@@ -127,7 +131,11 @@ export const Plugin = {
|
||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
|
||||
yield* config.update((draft) => {
|
||||
draft.websearch = {
|
||||
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
|
||||
}
|
||||
})
|
||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||
}),
|
||||
@@ -198,10 +206,7 @@ export const Plugin = {
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* websearch.default().pipe(
|
||||
Effect.as(false),
|
||||
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
|
||||
)
|
||||
const disabled = Config.latest(yield* config.entries(), "websearch") === false
|
||||
if (disabled) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, Layer, 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
|
||||
@@ -25,10 +24,6 @@ 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>
|
||||
}
|
||||
@@ -54,7 +49,6 @@ 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>
|
||||
}
|
||||
|
||||
@@ -62,14 +56,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
selection?: Selection
|
||||
selection?: ID | "random" | false
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => Selection | undefined
|
||||
set: (selection: Selection) => void
|
||||
get: () => ID | "random" | false | undefined
|
||||
set: (selection: ID | "random" | false) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +71,6 @@ 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() }),
|
||||
@@ -98,16 +91,12 @@ const layer = Layer.effect(
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
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") {
|
||||
if (data.selection === false) return yield* new DisabledError()
|
||||
if (data.selection === "random") {
|
||||
const providers = Array.from(data.providers.values())
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
return selection ? data.providers.get(selection) : undefined
|
||||
return data.selection ? data.providers.get(data.selection) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
@@ -131,9 +120,6 @@ 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(
|
||||
@@ -149,5 +135,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, KV.node],
|
||||
deps: [Bus.node],
|
||||
})
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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 { 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("merges image limits and reloads changed config", () =>
|
||||
Effect.gen(function* () {
|
||||
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 }),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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"))
|
||||
})
|
||||
@@ -150,16 +150,6 @@ describe("ConfigNormalize", () => {
|
||||
})
|
||||
|
||||
test("migrates the legacy small model to the title agent", () => {
|
||||
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
|
||||
expect(result.encoded.agents).toEqual({
|
||||
title: {
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
},
|
||||
})
|
||||
expect(result.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
test("merges the legacy small model with the title agent", () => {
|
||||
const result = normalized({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user