mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cbf607849 | |||
| 378d46c076 | |||
| e0d4a0374c | |||
| f6f8606155 | |||
| ef31823a6f | |||
| 4a42f4a724 | |||
| ce27f07f15 | |||
| 80587d936f | |||
| 09772d89d5 | |||
| 4f07025067 | |||
| f253bdaeef | |||
| 93c24cafa7 | |||
| 0963826120 | |||
| c5df2e5c28 | |||
| 13f0cdd0ea | |||
| 5887911c26 | |||
| 59bc653465 | |||
| 8a4c14aed5 | |||
| 15aef5f0f2 | |||
| 9b87120db6 | |||
| af6407a1b1 | |||
| e4ff762483 | |||
| a00822b06e | |||
| 58c33f6870 | |||
| 82d13c6715 | |||
| 6c1d066a65 | |||
| 9b8801a3c6 | |||
| 8dad59cf99 | |||
| a023ece71f | |||
| 2b5cc0090a | |||
| fc6b9e0dde | |||
| ab535c3875 | |||
| 4ca2f9dd83 | |||
| 044d04df06 | |||
| 4b9d89e943 | |||
| 5ff6bb87cf | |||
| 511b4556a2 | |||
| cb39ea1136 | |||
| ff9452bf03 | |||
| 97265f8ac5 | |||
| 56e66656b8 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Add transport-neutral Session model request hooks and provider-scoped hook registration so eligible OpenAI Responses requests can prefer WebSocket without bypassing HTTP-only middleware.
|
||||
@@ -82,6 +82,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# Sync Engine Plan (draft)
|
||||
|
||||
Redesign of client state management: consume the event-sourced server that
|
||||
already exists. Goal: **less code, more reliable** — optimism, reconnect, and
|
||||
retry become structural guarantees instead of defended special cases.
|
||||
|
||||
Status: outline for discussion. Sections marked `TODO` get filled in as we talk.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context ledger (receipts — do not lose these)
|
||||
|
||||
Facts established by code audit and prototyping, with locations.
|
||||
|
||||
### The server is already event-sourced
|
||||
|
||||
- Durable per-session event log in SQLite: `event(aggregate_id, seq, type, data)`
|
||||
+ `event_sequence` counter, unique index `(aggregate_id, seq)`
|
||||
(`packages/core/src/event/sql.ts`, migration `20260323234822_events`).
|
||||
- Event append, projections, and `commit` hooks all run in **one SQLite
|
||||
transaction** (`packages/core/src/bus.ts:249-370`, `behavior: "immediate"`).
|
||||
- 40 of 46 session event types are durable; the 6 ephemeral are streaming
|
||||
deltas/progress/usage ticks (`packages/schema/src/session-event.ts:626`).
|
||||
Durable events carry complete facts (`text.ended` has full text).
|
||||
- All hydration tables are written **only** by projectors inside the append
|
||||
transaction: `SessionMessageTable`, `SessionTable` → `session/projector.ts`;
|
||||
`SessionInboxTable` → `inbox.ts` `project*` fns called only from
|
||||
`projector.ts:521,555,571,577`. `SessionPendingTable` is dead (zero writers).
|
||||
- Message rows carry the aggregate seq of their creating event
|
||||
(`projector.ts:386`), and `Session.messages` already paginates by it
|
||||
(`session.ts:501`).
|
||||
- Replay+live log endpoint exists:
|
||||
`GET /api/experimental/session/:id/log?after=seq&follow=true`
|
||||
(`packages/protocol/src/groups/session.ts:631`). Only devtools consumes it.
|
||||
- Idempotent admission ~exists: `SessionInbox.admit` (`inbox.ts:137`) adopts an
|
||||
existing client-supplied `SessionMessage.ID`, re-checks after conflict
|
||||
defects. Gap: adopts without comparing payloads (no conflict on mismatch).
|
||||
- Reverts are committed server-side inside `Session.prompt`
|
||||
(`core/src/session.ts:567` at audit time) — client-side revert.commit is
|
||||
already redundant.
|
||||
|
||||
### The client ignores all of it
|
||||
|
||||
- The client data layer is `packages/client/src/solid/data.ts` (~1,530 lines;
|
||||
`packages/tui/src/context/data.tsx` is now a 22-line wrapper) — shared by
|
||||
TUI/desktop/web, so the engine lands once for all clients. Three racing
|
||||
write paths into one Solid store: a ~60-case bus-event switch, fetch
|
||||
handlers doing wholesale `reconcile()`, and (in PR #42807) an optimistic
|
||||
ledger both must consult. `sync.complete(key)` markers dedupe fetches but
|
||||
are not watermarks.
|
||||
- Opening a session today = four separate fetches: `session.get`,
|
||||
`session.list({parentID})` (children), `session.inbox.list`, messages
|
||||
(`data.ts:1084-1140`) — four sync keys, no shared consistent point.
|
||||
- Reconnect = `sync.reset()` + refetch everything; no gap proof.
|
||||
|
||||
### Prior art
|
||||
|
||||
- PRs #42807 (optimistic message send) and #42808 (optimistic session create):
|
||||
the stopgap. They landed client-generated IDs (keep) and ~15 hand-placed
|
||||
guard sites defending the optimistic ledger (delete when this plan ships).
|
||||
- Prototype: `~/code/open-source/sync-proto` — Effect v4 + Solid. Engine core
|
||||
~200 lines. Six laws as passing tests; seeded 2-client chaos sim (lost
|
||||
requests, lost responses after durable write, rejections, connection cuts,
|
||||
latency shifts) with convergence / ordering / uniqueness / no-flicker
|
||||
invariants; sim caught a real protocol bug (validation-before-adoption) on
|
||||
first run. Two-pane demo (await vs optimistic) with narrated scenarios.
|
||||
- Proposal doc: `~/code/open-source/opencode-sync-proposal/PROPOSAL.md`
|
||||
("Intents, Not Mutations").
|
||||
|
||||
### Vocabulary
|
||||
|
||||
- **fold** — pure reduction of durable events into state; same meaning on both
|
||||
sides. **intent** — a user action not yet acknowledged, client ID, in the
|
||||
**outbox**. **watermark** — the aggregate seq a snapshot reflects.
|
||||
**echo determinism** — an intent renders byte-identical to the row its
|
||||
admission event folds to, so acks are invisible.
|
||||
- The six laws: idempotency, echo determinism, sync opacity, ordering,
|
||||
convergence, failure atomicity.
|
||||
|
||||
### Ordering constraint (the one subtle thing)
|
||||
|
||||
Watermark reads must share a transaction with row reads:
|
||||
`seq`-then-rows double-applies accumulators; rows-then-`seq` loses events.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Optimism, reconnect, and retry are each defended point-by-point instead of
|
||||
guaranteed structurally; every new sync path re-fights the same races.
|
||||
- The client maintains a hand-written mutable mirror of state the server can
|
||||
already replay deterministically.
|
||||
|
||||
## 2. The claim: less code, more reliable
|
||||
|
||||
TODO — expand together. Sketch:
|
||||
|
||||
- Fold cases ≈ today's event switch (that code stays, becomes pure).
|
||||
- Deleted: optimistic ledger + guards, echo replace-in-place, sync-survival
|
||||
merges, `submitTails` throttling, `sync.complete` session keys,
|
||||
refetch-everything reconnect.
|
||||
- Added: engine core (~200 lines proven in prototype) + watermark plumbing.
|
||||
- Reliability from structure: the six laws hold by construction, not vigilance.
|
||||
|
||||
## 3. Design
|
||||
|
||||
```
|
||||
state = fold(snapshot, durable events) server truth only
|
||||
outbox = ordered pending intents client IDs, idempotent, serial resend
|
||||
overlay = live ephemeral fragments deltas/progress, superseded by durable facts
|
||||
view = render(state ⊕ outbox ⊕ overlay) derived
|
||||
```
|
||||
|
||||
The entire client surface for one session — two calls, one stream:
|
||||
|
||||
```
|
||||
GET /session/:id/snapshot fetch → { session, children, inbox,
|
||||
messages: last(N), seq }
|
||||
GET /session/:id/log?after=seq&follow=true one ordered stream:
|
||||
replay: durable events seq+1..head (deltas are live-only, absent here)
|
||||
marker: log.synced "caught up" — first-class engine signal
|
||||
live: durable + ephemeral session events interleaved, publish order
|
||||
```
|
||||
|
||||
- **Decided:** the log endpoint's follow phase carries ephemeral session
|
||||
events too (widen its union from `Durable | Synced`). One server-merged
|
||||
stream per session; the client never merges two event feeds for one
|
||||
aggregate, so a delta can never precede its own `Started`.
|
||||
- **Overlay semantics:** `Map<(messageID, ordinal), accumulated>` — never
|
||||
enters the fold. The durable full-value boundary (`text.ended`, carries
|
||||
complete text) supersedes and clears the overlay entry in the same atomic
|
||||
update that folds it — same no-seam trick as an intent leaving the outbox
|
||||
on ack. Dropped deltas are self-healing by construction. Same mechanism for
|
||||
all 6 ephemeral types (`tool.progress`/`usage.updated` superseded by
|
||||
`tool.success|failed`/`step.ended`).
|
||||
- **Decided (mid-stream reconnect):** accept today's behavior — after
|
||||
re-hydrate, overlay accumulates from now, so in-flight text may render with
|
||||
a missing prefix until its `Ended` supersedes. Simplest code, self-healing,
|
||||
no protocol change. (Optional later polish: if `Started` wasn't observed
|
||||
live, show a streaming indicator instead of partial text.)
|
||||
- Overlay entries render only once their base part exists in folded state
|
||||
(covers the one degenerate case: a live delta arriving during the replay
|
||||
window).
|
||||
- **Outbox ack contract: the echo is the only ack.** Intents carry a
|
||||
client-minted message ID. The HTTP 2xx is a latency hint, never trusted;
|
||||
the intent leaves the outbox only when the durable event carrying its ID
|
||||
folds in (same atomic store update — no flicker), or on a typed rejection
|
||||
(drop + surface). Timeout/disconnect = unknown fate = intent just stays.
|
||||
On reconnect: tail first, wait for `log.synced` (replay acks anything that
|
||||
landed), then resend unacked serially — adopt-on-same-ID (`inbox.ts:137`)
|
||||
makes resend duplicate-free. Lost request and lost response collapse into
|
||||
the same case ("no echo yet"), which is why no guard machinery survives.
|
||||
- **Contract: the snapshot is the only entry point.** The engine never replays
|
||||
a session from genesis; fold's domain is "snapshot + events after its seq."
|
||||
Forced by `session.forked` — the event carries only `{ parentID, boundary }`
|
||||
and the projector copies parent rows, which no client fold could reproduce.
|
||||
Bonus: licenses future log truncation (serve back to the oldest live
|
||||
snapshot, not seq 0).
|
||||
- Compaction/revert/fork all fold cleanly under that contract: compaction only
|
||||
adds (summary message + epoch marker); revert folds exactly what the
|
||||
projector does (drop messages/inbox at-or-after boundary, clear marker —
|
||||
`projector.ts:660-690`); fork needs no fold case at all (a forked session's
|
||||
snapshot already contains the copied messages).
|
||||
- Reconnect: tail from last seq first; on typed seq-unavailable → snapshot →
|
||||
resend unacknowledged → tail. Gapless by construction.
|
||||
- Scope boundary: session aggregate only. Ambient state (catalog, agents,
|
||||
projects, vcs, worktrees) stays on the existing global bus as-is.
|
||||
- Render target: existing Solid store shape, so components don't change.
|
||||
|
||||
## 4. Server changes (small)
|
||||
|
||||
- S1: watermarked hydration — **decided (tentative, pending prior-art review):**
|
||||
one new endpoint, `GET /api/session/:id/snapshot`, one read transaction:
|
||||
|
||||
```
|
||||
GET /api/session/:id/snapshot?recent=N
|
||||
→ { session, children, inbox, messages: last(N), seq }
|
||||
```
|
||||
|
||||
`recent` defaults to 200 — today's first page exactly (`data.ts:1136`,
|
||||
`limit: 200`). Tuning it smaller later is a parameter change, not a design
|
||||
change.
|
||||
|
||||
- Named `snapshot`, not `state`: point-in-time consistency is the contract,
|
||||
and `state` collides with the engine's `state = fold(...)`.
|
||||
- One transaction → one watermark. Rejected alternative: adding `seq` to
|
||||
each existing read — three watermarks force per-slice replay guards in
|
||||
the engine, and the session row's accumulators (usage sums) double-count
|
||||
on overlap replay. Server savings reappear as engine complexity, worse.
|
||||
- `children` are info rows only; child sessions are separate aggregates and
|
||||
hydrate their own messages/inbox when opened.
|
||||
- Older history stays on the existing seq-cursored `messages` endpoint,
|
||||
folded in as inert backfill (IDs dedupe; revert/compaction arrive on the
|
||||
log).
|
||||
- Prior art for snapshot+watermark+tail: Discord (`seq`/RESUME), Telegram
|
||||
(`pts`/`getDifference`), Linear (`lastSyncId`), Postgres logical
|
||||
replication (exported snapshot + LSN), Kafka/EventStore catch-up
|
||||
subscriptions, Replicache (cookie — whose launch checklist requires the
|
||||
watermark "read in the same transaction as the client view data").
|
||||
Slack's deprecated `rtm.start` is the cautionary tale for unbounded
|
||||
snapshots (their edge cache cut boot payloads 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,6 +905,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
|
||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
ADAPTER,
|
||||
state.tools,
|
||||
|
||||
@@ -1010,6 +1010,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores tool input deltas without a matching tool start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"orphaned"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles pending tool calls at message_stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -30,6 +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 scripts/bench-session-sync.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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 scripts/bench-session-sync.ts
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Dead ends
|
||||
|
||||
- WeakMap-memoized structural-sharing clone: unsound. `reconcile` merges
|
||||
nodes in place, so a memoized clone reachable from two store paths (or a
|
||||
later view) gets corrupted; caught by the TUI `updates session location
|
||||
when moved` test via fold state that aliases `previous.location`.
|
||||
- Microtask coalescing of publishes: each stream item already arrives on
|
||||
its own microtask, so `queueMicrotask` batching collapses nothing; real
|
||||
frame coalescing (~16ms timer) left unexplored as unnecessary at current
|
||||
numbers.
|
||||
@@ -0,0 +1,223 @@
|
||||
// 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 scripts/bench-session-sync.ts
|
||||
// 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
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
},
|
||||
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() {
|
||||
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
|
||||
},
|
||||
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 = [] as 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"
|
||||
@@ -139,36 +139,46 @@ export type Endpoint5_5Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_6Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly recent?: number | undefined }
|
||||
export type Endpoint5_6Output = {
|
||||
readonly session: Session.Info
|
||||
readonly children: ReadonlyArray<Session.Info>
|
||||
readonly inbox: ReadonlyArray<SessionInbox.Info>
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
readonly seq: Event.Seq
|
||||
}
|
||||
export type SessionSnapshotOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
|
||||
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
export type Endpoint5_7Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_7Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_8Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -179,10 +189,10 @@ export type Endpoint5_12Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -195,19 +205,19 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_14Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
export type Endpoint5_15Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_15Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -216,97 +226,98 @@ export type Endpoint5_15Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_15Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
export type Endpoint5_16Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_16Input = {
|
||||
export type Endpoint5_17Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type Endpoint5_17Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_18Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_17Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
export type Endpoint5_18Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_19Input = {
|
||||
export type Endpoint5_20Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_20Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
export type Endpoint5_29Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_30Input,
|
||||
) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_31Input = {
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_31Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_31Output =
|
||||
export type Endpoint5_32Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -894,24 +905,101 @@ export type Endpoint5_31Output =
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.usage.updated"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.text.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.reasoning.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly ordinal: number
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.input.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly delta: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.tool.progress"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly metadata: { readonly [x: string]: Schema.Json }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.compaction.delta"
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly text: string }
|
||||
}
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_34Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
|
||||
export type Endpoint5_35Output = void
|
||||
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_35Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export type Endpoint5_36Input = { readonly sessionID: Session.ID; readonly variables: { readonly [x: string]: string } }
|
||||
export type Endpoint5_36Output = void
|
||||
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -920,6 +1008,7 @@ export interface SessionApi<E = never> {
|
||||
readonly export: SessionExportOperation<E>
|
||||
readonly active: SessionActiveOperation<E>
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly snapshot: SessionSnapshotOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint5_36Input,
|
||||
Endpoint5_36Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -350,48 +352,56 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
|
||||
|
||||
const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
|
||||
preserveEffect<Endpoint5_6Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.snapshot"]({ params: { sessionID: input["sessionID"] }, query: { recent: input["recent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
|
||||
preserveEffect<Endpoint5_7Output>()(
|
||||
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -410,8 +420,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -432,16 +442,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -458,16 +468,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.compact"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], delivery: input["delivery"] },
|
||||
@@ -477,13 +487,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -493,27 +503,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -521,62 +523,70 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveStream<Endpoint5_32Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
@@ -584,29 +594,29 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
|
||||
preserveEffect<Endpoint5_36Output>()(
|
||||
raw["session.environment"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { variables: input["variables"] },
|
||||
@@ -620,29 +630,30 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
export: Endpoint5_3(raw),
|
||||
active: Endpoint5_4(raw),
|
||||
get: Endpoint5_5(raw),
|
||||
remove: Endpoint5_6(raw),
|
||||
fork: Endpoint5_7(raw),
|
||||
switchAgent: Endpoint5_8(raw),
|
||||
switchModel: Endpoint5_9(raw),
|
||||
rename: Endpoint5_10(raw),
|
||||
move: Endpoint5_11(raw),
|
||||
prompt: Endpoint5_12(raw),
|
||||
command: Endpoint5_13(raw),
|
||||
skill: Endpoint5_14(raw),
|
||||
synthetic: Endpoint5_15(raw),
|
||||
shell: Endpoint5_16(raw),
|
||||
compact: Endpoint5_17(raw),
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
environment: Endpoint5_35(raw),
|
||||
snapshot: Endpoint5_6(raw),
|
||||
remove: Endpoint5_7(raw),
|
||||
fork: Endpoint5_8(raw),
|
||||
switchAgent: Endpoint5_9(raw),
|
||||
switchModel: Endpoint5_10(raw),
|
||||
rename: Endpoint5_11(raw),
|
||||
move: Endpoint5_12(raw),
|
||||
prompt: Endpoint5_13(raw),
|
||||
command: Endpoint5_14(raw),
|
||||
skill: Endpoint5_15(raw),
|
||||
synthetic: Endpoint5_16(raw),
|
||||
shell: Endpoint5_17(raw),
|
||||
compact: Endpoint5_18(raw),
|
||||
wait: Endpoint5_19(raw),
|
||||
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
|
||||
context: Endpoint5_23(raw),
|
||||
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
|
||||
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
|
||||
generate: Endpoint5_31(raw),
|
||||
log: Endpoint5_32(raw),
|
||||
interrupt: Endpoint5_33(raw),
|
||||
background: Endpoint5_34(raw),
|
||||
message: Endpoint5_35(raw),
|
||||
environment: Endpoint5_36(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
SessionActiveOutput,
|
||||
SessionGetInput,
|
||||
SessionGetOutput,
|
||||
SessionSnapshotInput,
|
||||
SessionSnapshotOutput,
|
||||
SessionRemoveInput,
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
@@ -514,6 +516,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
snapshot: (input: SessionSnapshotInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionSnapshotOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/snapshot`,
|
||||
query: { recent: input["recent"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 500, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionRemoveOutput>(
|
||||
{
|
||||
@@ -843,9 +857,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/log`,
|
||||
query: { after: input["after"], follow: input["follow"] },
|
||||
query: { after: input["after"], follow: input["follow"], ephemeral: input["ephemeral"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
declaredStatuses: [404, 409, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -512,51 +512,6 @@ export type SessionRevertCommitted = {
|
||||
data: { sessionID: string; to: string }
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "models-dev.refreshed"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
}
|
||||
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "catalog.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type AgentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "agent.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -602,6 +557,51 @@ export type SessionCompactionDelta = {
|
||||
data: { sessionID: string; text: string }
|
||||
}
|
||||
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "models-dev.refreshed"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
}
|
||||
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "catalog.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type AgentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "agent.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1994,7 +1994,15 @@ export type FormCreated = {
|
||||
data: { form: FormInfo1 }
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
export type SessionLogItem =
|
||||
| SessionEventDurable
|
||||
| SessionUsageUpdated
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| EventLogSynced
|
||||
|
||||
export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string; form?: FormFields }
|
||||
|
||||
@@ -2004,6 +2012,16 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
|
||||
|
||||
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
|
||||
|
||||
export type SessionSnapshotResponse = {
|
||||
data: {
|
||||
session: SessionInfo
|
||||
children: Array<SessionInfo>
|
||||
inbox: Array<SessionInboxInfo>
|
||||
messages: Array<SessionMessageInfo>
|
||||
seq: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessagesResponse = {
|
||||
data: Array<SessionMessageInfo>
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
@@ -2218,6 +2236,16 @@ export const isInstructionEntryValueTooLargeError = (value: unknown): value is I
|
||||
"_tag" in value &&
|
||||
value["_tag"] === "InstructionEntryValueTooLargeError"
|
||||
|
||||
export type SeqUnavailableError = {
|
||||
readonly _tag: "SeqUnavailableError"
|
||||
readonly sessionID: string
|
||||
readonly after: number
|
||||
readonly head?: number | undefined
|
||||
readonly message: string
|
||||
}
|
||||
export const isSeqUnavailableError = (value: unknown): value is SeqUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SeqUnavailableError"
|
||||
|
||||
export type ProviderNotFoundError = {
|
||||
readonly _tag: "ProviderNotFoundError"
|
||||
readonly providerID: string
|
||||
@@ -3290,6 +3318,13 @@ export type SessionGetInput = { readonly sessionID: { readonly sessionID: string
|
||||
|
||||
export type SessionGetOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSnapshotInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly recent?: { readonly recent?: number | undefined }["recent"]
|
||||
}
|
||||
|
||||
export type SessionSnapshotOutput = SessionSnapshotResponse["data"]
|
||||
|
||||
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionRemoveOutput = void
|
||||
@@ -3928,8 +3963,21 @@ export type SessionGenerateOutput = SessionGenerateResponse["data"]
|
||||
|
||||
export type SessionLogInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
|
||||
readonly follow?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["follow"]
|
||||
readonly after?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["after"]
|
||||
readonly follow?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["follow"]
|
||||
readonly ephemeral?: {
|
||||
readonly after?: number | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
readonly ephemeral?: boolean | undefined
|
||||
}["ephemeral"]
|
||||
}
|
||||
|
||||
export type SessionLogOutput = SessionLogItem
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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.
|
||||
type StoreSessionView = {
|
||||
-readonly [Key in keyof Engine.SessionView]: Engine.SessionView[Key] extends ReadonlyArray<infer Item>
|
||||
? Item[]
|
||||
: Engine.SessionView[Key]
|
||||
}
|
||||
|
||||
const ambientSessionEvents = new Set<OpenCodeEvent["type"]>([
|
||||
"session.created",
|
||||
"session.deleted",
|
||||
"session.renamed",
|
||||
"session.execution.started",
|
||||
"session.execution.succeeded",
|
||||
"session.execution.failed",
|
||||
"session.execution.interrupted",
|
||||
])
|
||||
|
||||
export function createEngineTransport(api: () => SessionApi): Engine.SessionTransport {
|
||||
return {
|
||||
snapshot(sessionID) {
|
||||
return api().snapshot({ sessionID, recent: 200 })
|
||||
},
|
||||
async *stream(sessionID, after, signal) {
|
||||
try {
|
||||
for await (const item of api().log(
|
||||
{ sessionID, after, follow: true, ephemeral: true },
|
||||
signal ? { signal } : undefined,
|
||||
)) {
|
||||
if (item.type !== "session.forked") yield item
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSeqUnavailableError(error)) throw new Engine.SeqUnavailable()
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async submit(input) {
|
||||
try {
|
||||
await api().prompt({ ...input.request, sessionID: input.sessionID, id: input.id })
|
||||
} catch (error) {
|
||||
if (isTypedError(error)) throw new Engine.SubmitRejected(error.message)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createEngineData(config: CreateDataInput) {
|
||||
const legacy = createData({
|
||||
...config,
|
||||
event: {
|
||||
on: config.event.on,
|
||||
listen(handler) {
|
||||
return config.event.listen((event) => {
|
||||
if (event.name.startsWith("session.") && !ambientSessionEvents.has(event.name)) return
|
||||
handler(event)
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const [views, setViews] = createStore<Record<string, 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 (reconcile merges nodes in place, which corrupts shared clones). The
|
||||
// fold is a persistent structure — successive views share references for
|
||||
// everything unchanged — so diffing the previous engine view by identity and
|
||||
// deep-cloning only the changed subtrees keeps the boundary while avoiding
|
||||
// the full-view structuredClone per event that dominated the streaming path.
|
||||
const rendered = new Map<string, Engine.SessionView>()
|
||||
// Engine data is plain JSON, so a recursive copy beats structuredClone's
|
||||
// serialization overhead on the small per-event subtrees cloned below.
|
||||
const 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 update = (sessionID: string, view: Engine.SessionView) => {
|
||||
const previous = rendered.get(sessionID)
|
||||
rendered.set(sessionID, view)
|
||||
batch(() => {
|
||||
if (!previous || !views[sessionID]) setViews(sessionID, clone(view) as StoreSessionView)
|
||||
else {
|
||||
if (view.session !== previous.session) 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.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 (view.session !== previous?.session) {
|
||||
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(structuredClone(unwrap(child))))
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
status(sessionID: string) {
|
||||
if (views[sessionID]?.active === "running") return "running"
|
||||
return legacy.session.status(sessionID)
|
||||
},
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return (
|
||||
views[sessionID]?.pending.filter((item) => item.type !== "compaction").map((item) => item.id) ??
|
||||
legacy.session.input.list(sessionID)
|
||||
)
|
||||
},
|
||||
has(sessionID: string, inboxID: string) {
|
||||
return (
|
||||
views[sessionID]?.pending.some((item) => item.type !== "compaction" && item.id === inboxID) ??
|
||||
legacy.session.input.has(sessionID, inboxID)
|
||||
)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
list(sessionID: string) {
|
||||
void ensure(sessionID)
|
||||
return [...(views[sessionID]?.pending ?? [])]
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync(sessionID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
},
|
||||
message: {
|
||||
list(sessionID: string) {
|
||||
void ensure(sessionID)
|
||||
return [...(views[sessionID]?.messages ?? [])]
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
void ensure(sessionID)
|
||||
return views[sessionID]?.messages.find((message) => message.id === messageID)
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync(sessionID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
invalidated.add(sessionID)
|
||||
},
|
||||
},
|
||||
async prompt(input: SessionPromptInput) {
|
||||
return (await ensure(input.sessionID)).submit({
|
||||
id: input.id ?? undefined,
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
metadata: input.metadata,
|
||||
delivery: input.delivery,
|
||||
resume: input.resume,
|
||||
})
|
||||
},
|
||||
failures: {
|
||||
listen(listener: (failure: Engine.IntentFailure) => void) {
|
||||
failures.add(listener)
|
||||
return () => failures.delete(listener)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isTypedError(error: unknown): error is { readonly _tag: string; readonly message: string } {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"_tag" in error &&
|
||||
typeof error._tag === "string" &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
)
|
||||
}
|
||||
|
||||
export type EngineData = ReturnType<typeof createEngineData>
|
||||
@@ -0,0 +1,461 @@
|
||||
import type {
|
||||
EventLogSynced,
|
||||
SessionCompactionDelta,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionMessageInfo,
|
||||
SessionPromptInput,
|
||||
SessionReasoningDelta,
|
||||
SessionTextDelta,
|
||||
SessionToolInputDelta,
|
||||
SessionToolProgress,
|
||||
SessionUsageUpdated,
|
||||
} from "../../promise"
|
||||
import { SessionFold } from "./fold"
|
||||
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "./fold"
|
||||
|
||||
export type EphemeralSessionEvent =
|
||||
| SessionTextDelta
|
||||
| SessionReasoningDelta
|
||||
| SessionToolInputDelta
|
||||
| SessionToolProgress
|
||||
| SessionCompactionDelta
|
||||
| SessionUsageUpdated
|
||||
|
||||
export type SessionStreamItem = DurableSessionEvent | EphemeralSessionEvent | EventLogSynced
|
||||
|
||||
export type Intent = {
|
||||
readonly id: string
|
||||
readonly item: Extract<SessionInboxItem, { readonly type: "user" | "synthetic" }>
|
||||
readonly request: Omit<SessionPromptInput, "sessionID" | "id">
|
||||
readonly created: number
|
||||
}
|
||||
|
||||
export type SubmitInput = {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly request: Intent["request"]
|
||||
}
|
||||
|
||||
export type IntentFailure = {
|
||||
readonly intent: Intent
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export class SubmitRejected extends Error {
|
||||
readonly _tag = "SubmitRejected"
|
||||
|
||||
constructor(readonly reason: string) {
|
||||
super(reason)
|
||||
}
|
||||
}
|
||||
|
||||
export class SeqUnavailable extends Error {
|
||||
readonly _tag = "SeqUnavailable"
|
||||
}
|
||||
|
||||
export interface SessionTransport {
|
||||
readonly snapshot: (sessionID: string) => Promise<SessionSnapshot>
|
||||
readonly stream: (sessionID: string, after: number, signal?: AbortSignal) => AsyncIterable<SessionStreamItem>
|
||||
readonly submit: (input: SubmitInput) => Promise<void>
|
||||
}
|
||||
|
||||
export type SessionView = SessionFoldState & {
|
||||
readonly pending: ReadonlyArray<SessionInboxInfo>
|
||||
}
|
||||
|
||||
export interface SessionEngine {
|
||||
readonly sessionID: string
|
||||
readonly view: () => SessionView
|
||||
readonly submit: (input: Intent["request"] & { readonly id?: string }) => Intent
|
||||
readonly subscribe: (listener: (view: SessionView) => void) => () => void
|
||||
readonly subscribeFailures: (listener: (failure: IntentFailure) => void) => () => void
|
||||
readonly ready: () => Promise<void>
|
||||
readonly refresh: () => Promise<void>
|
||||
readonly settled: () => Promise<void>
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
export type SessionEngineOptions = {
|
||||
readonly makeID?: () => string
|
||||
readonly now?: () => number
|
||||
readonly reconnect?: () => Promise<void>
|
||||
}
|
||||
|
||||
type Overlay = ReadonlyMap<string, OverlayEntry>
|
||||
|
||||
type OverlayEntry =
|
||||
| { readonly type: "text"; readonly value: string }
|
||||
| { readonly type: "reasoning"; readonly value: string }
|
||||
| { readonly type: "tool-input"; readonly value: string }
|
||||
| { readonly type: "tool-progress"; readonly metadata: SessionToolProgress["data"]["metadata"] }
|
||||
| { readonly type: "compaction"; readonly value: string }
|
||||
| { readonly type: "usage"; readonly value: SessionUsageUpdated["data"] }
|
||||
|
||||
type EngineState = {
|
||||
readonly folded: SessionFoldState
|
||||
readonly outbox: ReadonlyArray<Intent>
|
||||
readonly overlay: Overlay
|
||||
readonly synced: boolean
|
||||
}
|
||||
|
||||
export async function createSessionEngine(
|
||||
sessionID: string,
|
||||
transport: SessionTransport,
|
||||
options: SessionEngineOptions = {},
|
||||
): Promise<SessionEngine> {
|
||||
let counter = 0
|
||||
const makeID = options.makeID ?? (() => `msg_${Date.now().toString(36)}_${++counter}`)
|
||||
const now = options.now ?? Date.now
|
||||
const reconnect = options.reconnect ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 100)))
|
||||
let state: EngineState = {
|
||||
folded: SessionFold.fromSnapshot(await transport.snapshot(sessionID)),
|
||||
outbox: [],
|
||||
overlay: new Map(),
|
||||
synced: false,
|
||||
}
|
||||
const listeners = new Set<(view: SessionView) => void>()
|
||||
const failureListeners = new Set<(failure: IntentFailure) => void>()
|
||||
const settled = new Set<() => void>()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let sent: string | undefined
|
||||
let stopped = false
|
||||
let sending = false
|
||||
let refreshing: Promise<void> | undefined
|
||||
const abort = new AbortController()
|
||||
|
||||
const publish = (next: EngineState) => {
|
||||
state = next
|
||||
const view = render(state)
|
||||
listeners.forEach((listener) => listener(view))
|
||||
if (state.outbox.length > 0) return
|
||||
settled.forEach((resolve) => resolve())
|
||||
settled.clear()
|
||||
}
|
||||
|
||||
const applySnapshot = (snapshot: SessionSnapshot, synced = false) => {
|
||||
const folded = SessionFold.fromSnapshot(snapshot)
|
||||
const acknowledged = new Set([
|
||||
...folded.messages.map((message) => message.id),
|
||||
...folded.inbox.map((item) => item.id),
|
||||
])
|
||||
publish({
|
||||
folded,
|
||||
outbox: state.outbox.filter((intent) => !acknowledged.has(intent.id)),
|
||||
overlay: new Map(),
|
||||
synced,
|
||||
})
|
||||
}
|
||||
|
||||
const applyDurable = (event: DurableSessionEvent) => {
|
||||
if (event.type === "session.inbox.enqueued" && sent === event.data.inboxID) sent = undefined
|
||||
publish({
|
||||
folded: SessionFold.apply(state.folded, event),
|
||||
outbox:
|
||||
event.type === "session.inbox.enqueued"
|
||||
? state.outbox.filter((intent) => intent.id !== event.data.inboxID)
|
||||
: state.outbox,
|
||||
overlay: clearOverlay(state.overlay, event),
|
||||
synced: state.synced,
|
||||
})
|
||||
send()
|
||||
}
|
||||
|
||||
const reject = (intent: Intent, reason: string) => {
|
||||
publish({ ...state, outbox: state.outbox.filter((item) => item.id !== intent.id) })
|
||||
failureListeners.forEach((listener) => listener({ intent, reason }))
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
if (!state.synced || sending || stopped) return
|
||||
const intent = state.outbox[0]
|
||||
if (!intent || sent === intent.id) return
|
||||
sending = true
|
||||
sent = intent.id
|
||||
void (async () => {
|
||||
try {
|
||||
await transport.submit({ id: intent.id, sessionID, request: intent.request })
|
||||
} catch (error) {
|
||||
if (!(error instanceof SubmitRejected)) return
|
||||
sent = undefined
|
||||
reject(intent, error.reason)
|
||||
}
|
||||
})().finally(() => {
|
||||
sending = false
|
||||
send()
|
||||
})
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
for await (const item of transport.stream(sessionID, state.folded.seq, abort.signal)) {
|
||||
if (stopped) return
|
||||
if (item.type === "log.synced") {
|
||||
// A marker past the fold means the server skipped events it could not
|
||||
// replay for this cursor; recover through a fresh snapshot.
|
||||
if (item.seq !== undefined && item.seq > state.folded.seq) throw new SeqUnavailable()
|
||||
sent = undefined
|
||||
publish({ ...state, synced: true })
|
||||
ready.resolve()
|
||||
send()
|
||||
continue
|
||||
}
|
||||
if ("durable" in item) {
|
||||
applyDurable(item)
|
||||
continue
|
||||
}
|
||||
publish({ ...state, overlay: applyOverlay(state.overlay, item) })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SeqUnavailable) {
|
||||
try {
|
||||
applySnapshot(await transport.snapshot(sessionID))
|
||||
} catch {
|
||||
await reconnect()
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (stopped) return
|
||||
publish({ ...state, synced: false })
|
||||
await reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
void sync()
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
view: () => render(state),
|
||||
submit(input) {
|
||||
const intent: Intent = {
|
||||
id: input.id ?? makeID(),
|
||||
created: now(),
|
||||
request: input,
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: input.delivery ?? "steer",
|
||||
payload: {
|
||||
text: input.text,
|
||||
agents: input.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.metadata,
|
||||
},
|
||||
},
|
||||
}
|
||||
publish({ ...state, outbox: [...state.outbox, intent] })
|
||||
send()
|
||||
return intent
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
subscribeFailures(listener) {
|
||||
failureListeners.add(listener)
|
||||
return () => failureListeners.delete(listener)
|
||||
},
|
||||
ready: () => ready.promise,
|
||||
refresh() {
|
||||
if (refreshing) return refreshing
|
||||
refreshing = transport
|
||||
.snapshot(sessionID)
|
||||
.then((snapshot) => {
|
||||
if (snapshot.seq < state.folded.seq) return
|
||||
applySnapshot(snapshot, state.synced)
|
||||
send()
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = undefined
|
||||
})
|
||||
return refreshing
|
||||
},
|
||||
settled() {
|
||||
if (state.outbox.length === 0) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => settled.add(resolve))
|
||||
},
|
||||
stop() {
|
||||
stopped = true
|
||||
abort.abort()
|
||||
publish({ ...state, synced: false })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function render(state: Pick<EngineState, "folded" | "outbox" | "overlay">): SessionView {
|
||||
// Render runs per ephemeral event, so preserve references for everything the
|
||||
// event did not touch: the adapter diffs consecutive views by identity to
|
||||
// decide what to write into the reactive store.
|
||||
const pending =
|
||||
state.outbox.length === 0
|
||||
? state.folded.inbox
|
||||
: [
|
||||
...state.folded.inbox,
|
||||
...state.outbox.map(
|
||||
(intent): SessionInboxInfo => ({
|
||||
id: intent.id,
|
||||
sessionID: state.folded.session.id,
|
||||
timeCreated: intent.created,
|
||||
...intent.item,
|
||||
}),
|
||||
),
|
||||
]
|
||||
const appended = pendingMessages(state.folded, pending)
|
||||
const messages = applyOverlayToMessages(
|
||||
appended.length === 0 ? state.folded.messages : [...state.folded.messages, ...appended],
|
||||
state.overlay,
|
||||
)
|
||||
const usage = state.overlay.get("usage")
|
||||
return {
|
||||
...state.folded,
|
||||
session:
|
||||
usage?.type === "usage"
|
||||
? { ...state.folded.session, cost: usage.value.cost, tokens: usage.value.tokens }
|
||||
: state.folded.session,
|
||||
messages,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
function 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] : []
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
// Part keys embed the message ID as the second segment; remap only messages
|
||||
// the overlay actually touches so everything else keeps its identity.
|
||||
const touched = new Set<string>()
|
||||
let compacting = false
|
||||
overlay.forEach((_, key) => {
|
||||
if (key === "compaction") compacting = true
|
||||
if (key === "compaction" || key === "usage") return
|
||||
touched.add(key.split(":")[1] ?? "")
|
||||
})
|
||||
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}`
|
||||
}
|
||||
|
||||
export * as Engine from "./engine"
|
||||
@@ -0,0 +1,580 @@
|
||||
import type {
|
||||
SessionEventDurable,
|
||||
SessionInboxInfo,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
TokenUsageInfo,
|
||||
} from "../../promise"
|
||||
|
||||
export type SessionFoldState = {
|
||||
readonly session: SessionInfo
|
||||
readonly children: ReadonlyArray<SessionInfo>
|
||||
readonly inbox: ReadonlyArray<SessionInboxInfo>
|
||||
readonly messages: ReadonlyArray<SessionMessageInfo>
|
||||
readonly active: "idle" | "running"
|
||||
readonly deleted: boolean
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
export type SessionSnapshot = Omit<SessionFoldState, "active" | "deleted"> & {
|
||||
readonly active?: SessionFoldState["active"]
|
||||
}
|
||||
|
||||
export type DurableSessionEvent = Exclude<SessionEventDurable, { readonly type: "session.forked" }>
|
||||
|
||||
export function fromSnapshot(snapshot: SessionSnapshot): SessionFoldState {
|
||||
return { ...snapshot, active: snapshot.active ?? "idle", deleted: false }
|
||||
}
|
||||
|
||||
export function apply(state: SessionFoldState, event: DurableSessionEvent): SessionFoldState {
|
||||
if (event.durable.seq <= state.seq) return state
|
||||
const current = { ...state, seq: event.durable.seq }
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
return current
|
||||
case "session.deleted":
|
||||
return { ...current, deleted: true }
|
||||
case "session.usage.recorded":
|
||||
return { ...current, session: addUsage(state.session, event.data.cost, event.data.tokens, event.created) }
|
||||
case "session.agent.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
agent: event.data.agent,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous: event.data.previous ?? state.session.agent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.model.selected":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
model: event.data.model,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
previous: event.data.previous ?? state.session.model,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.moved":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: messageID(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous: {
|
||||
location: state.session.location,
|
||||
projectID: state.session.projectID,
|
||||
subpath: state.session.subpath,
|
||||
},
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.renamed":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, title: event.data.title, time: { ...state.session.time, updated: event.created } },
|
||||
}
|
||||
case "session.inbox.enqueued":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, time: { ...state.session.time, updated: event.created } },
|
||||
inbox: state.inbox.some((item) => item.id === event.data.inboxID)
|
||||
? state.inbox
|
||||
: [
|
||||
...state.inbox,
|
||||
{
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
},
|
||||
],
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const item = state.inbox.find((item) => item.id === event.data.inboxID)
|
||||
const next = { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
if (!item) return next
|
||||
const delivered = messageFromInbox(item, event.created)
|
||||
return delivered ? append(next, delivered) : next
|
||||
}
|
||||
case "session.inbox.cancelled":
|
||||
return { ...current, inbox: state.inbox.filter((item) => item.id !== event.data.inboxID) }
|
||||
case "session.inbox.delivery.changed":
|
||||
return {
|
||||
...current,
|
||||
inbox: state.inbox.map((item) =>
|
||||
item.id === event.data.inboxID ? { ...item, delivery: event.data.delivery } : item,
|
||||
),
|
||||
}
|
||||
case "session.execution.started":
|
||||
return { ...current, active: "running" }
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
return { ...updateActiveAssistant(current, (message) => ({ ...message, retry: undefined })), active: "idle" }
|
||||
case "session.instructions.updated":
|
||||
if (event.data.text === undefined) return current
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.type === "shell" && message.shellID === event.data.shell.id,
|
||||
(message) => {
|
||||
if (message.type !== "shell") return message
|
||||
return {
|
||||
...message,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...message.time, completed: event.created },
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const existing = state.messages.some((message) => message.id === event.data.assistantMessageID)
|
||||
if (existing)
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
time: { ...message.time, completed: undefined },
|
||||
snapshot: event.data.snapshot ? { ...message.snapshot, start: event.data.snapshot } : message.snapshot,
|
||||
}))
|
||||
return append(
|
||||
updateActiveAssistant(current, (message) => ({
|
||||
...message,
|
||||
retry: undefined,
|
||||
time: { ...message.time, completed: event.created },
|
||||
})),
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.metadata,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return withUsage(
|
||||
updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
})),
|
||||
event.data.cost,
|
||||
event.data.tokens,
|
||||
event.created,
|
||||
)
|
||||
case "session.step.failed": {
|
||||
const failed = updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? message.cost,
|
||||
tokens: event.data.tokens ?? message.tokens,
|
||||
time: { ...message.time, completed: event.created },
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...message.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: message.snapshot,
|
||||
}))
|
||||
if (event.data.cost === undefined || event.data.tokens === undefined) return failed
|
||||
return withUsage(failed, event.data.cost, event.data.tokens, event.created)
|
||||
}
|
||||
case "session.text.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "text", event.data.ordinal, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
state: event.data.state,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: insertOrdinal(message.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(current, event.data.assistantMessageID, "reasoning", event.data.ordinal, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? part.state,
|
||||
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||
}))
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
content: [
|
||||
...message.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) =>
|
||||
part.state.status === "streaming" ? { ...part, state: { ...part.state, input: event.data.text } } : part,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => ({
|
||||
...part,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, metadata: {} },
|
||||
time: { ...part.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.success":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(current, event.data.assistantMessageID, event.data.id, (part) => {
|
||||
if (part.state.status !== "streaming" && part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||
content: event.data.content,
|
||||
metadata: event.data.metadata,
|
||||
},
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(current, event.data.assistantMessageID, (message) => ({
|
||||
...message,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.compaction.started":
|
||||
return append(
|
||||
{
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
},
|
||||
{
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
if (!running)
|
||||
return append(current, {
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return updateMessage(
|
||||
current,
|
||||
(message) => message.id === running.id,
|
||||
(message) => ({
|
||||
...message,
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const running = state.messages.findLast(
|
||||
(message) => message.type === "compaction" && message.status === "running",
|
||||
)
|
||||
const failed = {
|
||||
id: running?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction" as const,
|
||||
status: "failed" as const,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
metadata: running?.metadata ?? event.metadata,
|
||||
time: running?.time ?? { created: event.created },
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
inbox: event.data.inputID ? state.inbox.filter((item) => item.id !== event.data.inputID) : state.inbox,
|
||||
}
|
||||
return running
|
||||
? updateMessage(
|
||||
next,
|
||||
(message) => message.id === running.id,
|
||||
() => failed,
|
||||
)
|
||||
: append(next, failed)
|
||||
}
|
||||
case "session.revert.staged":
|
||||
return {
|
||||
...current,
|
||||
session: {
|
||||
...state.session,
|
||||
revert: event.data.revert,
|
||||
time: { ...state.session.time, updated: event.created },
|
||||
},
|
||||
}
|
||||
case "session.revert.cleared":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
|
||||
}
|
||||
case "session.revert.committed":
|
||||
return {
|
||||
...current,
|
||||
session: { ...state.session, revert: undefined, time: { ...state.session.time, updated: event.created } },
|
||||
messages: state.messages.filter((message) => message.id < event.data.to),
|
||||
inbox: state.inbox.filter((item) => item.id < event.data.to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
export function messageFromInbox(item: SessionInboxInfo, created = item.timeCreated): SessionMessageInfo | undefined {
|
||||
if (item.type === "user") return { id: item.id, type: "user", ...item.payload, time: { created } }
|
||||
if (item.type === "synthetic") return { id: item.id, type: "synthetic", ...item.payload, time: { created } }
|
||||
}
|
||||
|
||||
function append(state: SessionFoldState, item: SessionMessageInfo) {
|
||||
if (state.messages.some((message) => message.id === item.id)) return state
|
||||
return { ...state, messages: [...state.messages, item] }
|
||||
}
|
||||
|
||||
function updateMessage(
|
||||
state: SessionFoldState,
|
||||
predicate: (message: SessionMessageInfo) => boolean,
|
||||
update: (message: SessionMessageInfo) => SessionMessageInfo,
|
||||
last = false,
|
||||
) {
|
||||
const index = last ? state.messages.findLastIndex(predicate) : state.messages.findIndex(predicate)
|
||||
if (index < 0) return state
|
||||
return {
|
||||
...state,
|
||||
messages: state.messages.map((message, position) => (position === index ? update(message) : message)),
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.id === messageID && message.type === "assistant",
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
)
|
||||
}
|
||||
|
||||
function updateActiveAssistant(
|
||||
state: SessionFoldState,
|
||||
update: (message: SessionMessageAssistant) => SessionMessageAssistant,
|
||||
) {
|
||||
return updateMessage(
|
||||
state,
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
(message) => (message.type === "assistant" ? update(message) : message),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
function updateContent<Type extends "text" | "reasoning">(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
update: (
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) => Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const position = message.content.flatMap((part, index) => (part.type === type ? [index] : []))[ordinal]
|
||||
const part = position === undefined ? undefined : message.content[position]
|
||||
if (!part || part.type !== type) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((item, index) =>
|
||||
index === position
|
||||
? update(part as Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>)
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
state: SessionFoldState,
|
||||
messageID: string,
|
||||
toolID: string,
|
||||
update: (part: SessionMessageAssistantTool) => SessionMessageAssistantTool,
|
||||
) {
|
||||
return updateAssistant(state, messageID, (message) => {
|
||||
const index = message.content.findLastIndex((part) => part.type === "tool" && part.id === toolID)
|
||||
if (index < 0) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part, position) =>
|
||||
position === index && part.type === "tool" ? update(part) : part,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertOrdinal<Type extends SessionMessageAssistant["content"][number]["type"]>(
|
||||
content: SessionMessageAssistant["content"],
|
||||
type: Type,
|
||||
ordinal: number,
|
||||
part: Extract<SessionMessageAssistant["content"][number], { readonly type: Type }>,
|
||||
) {
|
||||
if (content.filter((item) => item.type === type)[ordinal]) return content
|
||||
return [...content, part]
|
||||
}
|
||||
|
||||
function addUsage(session: SessionInfo, cost: number, tokens: TokenUsageInfo, updated: number): SessionInfo {
|
||||
return {
|
||||
...session,
|
||||
cost: session.cost + cost,
|
||||
tokens: {
|
||||
input: session.tokens.input + tokens.input,
|
||||
output: session.tokens.output + tokens.output,
|
||||
reasoning: session.tokens.reasoning + tokens.reasoning,
|
||||
cache: {
|
||||
read: session.tokens.cache.read + tokens.cache.read,
|
||||
write: session.tokens.cache.write + tokens.cache.write,
|
||||
},
|
||||
},
|
||||
time: { ...session.time, updated },
|
||||
}
|
||||
}
|
||||
|
||||
function withUsage(state: SessionFoldState, cost: number, tokens: TokenUsageInfo, updated: number) {
|
||||
return { ...state, session: addUsage(state.session, cost, tokens, updated) }
|
||||
}
|
||||
|
||||
export * as SessionFold from "./fold"
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./engine-data"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { createEngineTransport } from "../src/solid/engine-data"
|
||||
import { FakeSessionServer } from "./fixture/sync-engine"
|
||||
|
||||
describe("engine data transport", () => {
|
||||
test("uses snapshot and ephemeral follow log contracts", async () => {
|
||||
const server = new FakeSessionServer("session-transport")
|
||||
const calls: Array<unknown> = []
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot(input) {
|
||||
calls.push(input)
|
||||
return server.snapshotValue()
|
||||
},
|
||||
async *log(input) {
|
||||
calls.push(input)
|
||||
yield { type: "log.synced" as const, aggregateID: input.sessionID, seq: 0 }
|
||||
},
|
||||
async prompt() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
}))
|
||||
|
||||
expect(await transport.snapshot(server.sessionID)).toEqual(server.snapshotValue())
|
||||
const items = []
|
||||
for await (const item of transport.stream(server.sessionID, 0)) items.push(item)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: server.sessionID, seq: 0 }])
|
||||
expect(calls).toEqual([
|
||||
{ sessionID: server.sessionID, recent: 200 },
|
||||
{ sessionID: server.sessionID, after: 0, follow: true, ephemeral: true },
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves the prompt request and client-minted ID", async () => {
|
||||
const requests: Array<unknown> = []
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async *log() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async prompt(input) {
|
||||
requests.push(input)
|
||||
return {
|
||||
id: input.id!,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
await transport.submit({
|
||||
id: "msg_client",
|
||||
sessionID: "session-submit",
|
||||
request: {
|
||||
id: undefined,
|
||||
sessionID: undefined,
|
||||
text: "hello",
|
||||
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
id: "msg_client",
|
||||
sessionID: "session-submit",
|
||||
text: "hello",
|
||||
files: [{ uri: "file:///tmp/example.txt", name: "example.txt" }],
|
||||
delivery: "queue",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("translates generated typed failures", async () => {
|
||||
const transport = createEngineTransport(() => ({
|
||||
async snapshot() {
|
||||
throw new Error("unused")
|
||||
},
|
||||
async *log() {
|
||||
throw { _tag: "SeqUnavailableError", sessionID: "session", after: 2, head: 1, message: "gone" }
|
||||
},
|
||||
async prompt() {
|
||||
throw { _tag: "InvalidRequestError", message: "invalid" }
|
||||
},
|
||||
}))
|
||||
|
||||
const streamError = await collectError(transport.stream("session", 2))
|
||||
expect(streamError).toBeInstanceOf(Engine.SeqUnavailable)
|
||||
await expect(
|
||||
transport.submit({ id: "msg_client", sessionID: "session", request: { text: "invalid" } }),
|
||||
).rejects.toEqual(new Engine.SubmitRejected("invalid"))
|
||||
})
|
||||
})
|
||||
|
||||
async function collectError(iterable: AsyncIterable<unknown>) {
|
||||
try {
|
||||
for await (const item of iterable) void item
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
throw new Error("stream did not fail")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { SessionInfo, SessionMessageInfo } from "../../src/promise"
|
||||
import { Engine } from "../../src/solid/engine/engine"
|
||||
import type { DurableSessionEvent, SessionFoldState, SessionSnapshot } from "../../src/solid/engine/fold"
|
||||
import { SessionFold } from "../../src/solid/engine/fold"
|
||||
|
||||
export class FakeSessionServer implements Engine.SessionTransport {
|
||||
readonly events: Array<DurableSessionEvent> = []
|
||||
readonly admitted: Array<string> = []
|
||||
readonly faults = {
|
||||
loseRequests: 0,
|
||||
loseResponses: 0,
|
||||
reject: 0,
|
||||
latency: 0,
|
||||
}
|
||||
|
||||
private folded: SessionFoldState
|
||||
private readonly tails = new Set<AsyncQueue<Engine.SessionStreamItem>>()
|
||||
private eventCounter = 0
|
||||
|
||||
constructor(
|
||||
readonly sessionID: string,
|
||||
readonly time = 1_717_171_717_000,
|
||||
) {
|
||||
this.folded = SessionFold.fromSnapshot(emptySnapshot(sessionID))
|
||||
}
|
||||
|
||||
async snapshot(sessionID: string) {
|
||||
await this.pause()
|
||||
this.assertSession(sessionID)
|
||||
return this.snapshotValue()
|
||||
}
|
||||
|
||||
async *stream(sessionID: string, after: number): AsyncIterable<Engine.SessionStreamItem> {
|
||||
await this.pause()
|
||||
this.assertSession(sessionID)
|
||||
if (after > this.folded.seq) throw new Engine.SeqUnavailable()
|
||||
const replay = this.events.filter((event) => event.durable.seq > after)
|
||||
// Honest replay contract: a cursor is only admitted when retained events fully cover (after, seq].
|
||||
if (replay.length < this.folded.seq - after) throw new Engine.SeqUnavailable()
|
||||
const queue = new AsyncQueue<Engine.SessionStreamItem>()
|
||||
this.tails.add(queue)
|
||||
try {
|
||||
for (const event of replay) yield event
|
||||
yield { type: "log.synced", aggregateID: sessionID, seq: this.folded.seq }
|
||||
while (true) yield await queue.take()
|
||||
} finally {
|
||||
this.tails.delete(queue)
|
||||
}
|
||||
}
|
||||
|
||||
async submit(input: Engine.SubmitInput) {
|
||||
await this.pause()
|
||||
this.assertSession(input.sessionID)
|
||||
const existing = this.events.find(
|
||||
(event) => event.type === "session.inbox.enqueued" && event.data.inboxID === input.id,
|
||||
)
|
||||
if (existing) return
|
||||
if (this.faults.loseRequests > 0) {
|
||||
this.faults.loseRequests--
|
||||
throw new Error("request lost")
|
||||
}
|
||||
if (this.faults.reject > 0) {
|
||||
this.faults.reject--
|
||||
throw new Engine.SubmitRejected("rejected")
|
||||
}
|
||||
this.admitted.push(input.id)
|
||||
this.publish({
|
||||
id: `evt_${String(++this.eventCounter).padStart(8, "0")}`,
|
||||
created: this.time,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: this.sessionID, seq: this.folded.seq + 1, version: 1 },
|
||||
data: {
|
||||
sessionID: this.sessionID,
|
||||
inboxID: input.id,
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: input.request.delivery ?? "steer",
|
||||
payload: {
|
||||
text: input.request.text,
|
||||
agents: input.request.agents?.map((agent) => ({ ...agent })),
|
||||
metadata: input.request.metadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (this.faults.loseResponses > 0) {
|
||||
this.faults.loseResponses--
|
||||
throw new Error("response lost")
|
||||
}
|
||||
}
|
||||
|
||||
cutConnections() {
|
||||
this.tails.forEach((tail) => tail.fail(new Error("connection cut")))
|
||||
}
|
||||
|
||||
/** Drop retained event history, simulating `events.persist` off or pruned retention. */
|
||||
prune() {
|
||||
this.events.length = 0
|
||||
}
|
||||
|
||||
truth() {
|
||||
return Engine.render({ folded: this.folded, outbox: [], overlay: new Map() })
|
||||
}
|
||||
|
||||
snapshotValue(): SessionSnapshot {
|
||||
return {
|
||||
session: this.folded.session,
|
||||
children: this.folded.children,
|
||||
inbox: this.folded.inbox,
|
||||
messages: this.folded.messages,
|
||||
seq: this.folded.seq,
|
||||
active: this.folded.active,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(event: DurableSessionEvent) {
|
||||
this.events.push(event)
|
||||
this.folded = SessionFold.apply(this.folded, event)
|
||||
this.tails.forEach((tail) => tail.offer(event))
|
||||
}
|
||||
|
||||
private assertSession(sessionID: string) {
|
||||
if (sessionID !== this.sessionID) throw new Error(`unknown session: ${sessionID}`)
|
||||
}
|
||||
|
||||
private async pause() {
|
||||
for (let step = 0; step < this.faults.latency; step++) await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export async function until(check: () => boolean, message = "condition did not become true") {
|
||||
for (let attempt = 0; attempt < 500; attempt++) {
|
||||
if (check()) return
|
||||
await Bun.sleep(1)
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export function userMessages(messages: ReadonlyArray<SessionMessageInfo>) {
|
||||
return messages.filter(
|
||||
(message): message is Extract<SessionMessageInfo, { readonly type: "user" }> => message.type === "user",
|
||||
)
|
||||
}
|
||||
|
||||
function emptySnapshot(sessionID: string): SessionSnapshot {
|
||||
const session: SessionInfo = {
|
||||
id: sessionID,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1_717_171_717_000, updated: 1_717_171_717_000 },
|
||||
location: { directory: "/workspace" },
|
||||
}
|
||||
return { session, children: [], inbox: [], messages: [], seq: 0 }
|
||||
}
|
||||
|
||||
class AsyncQueue<Value> {
|
||||
private readonly values: Array<Value> = []
|
||||
private readonly waiting: Array<{
|
||||
readonly resolve: (value: Value) => void
|
||||
readonly reject: (error: Error) => void
|
||||
}> = []
|
||||
private error?: Error
|
||||
|
||||
offer(value: Value) {
|
||||
const waiter = this.waiting.shift()
|
||||
if (waiter) {
|
||||
waiter.resolve(value)
|
||||
return
|
||||
}
|
||||
this.values.push(value)
|
||||
}
|
||||
|
||||
fail(error: Error) {
|
||||
this.error = error
|
||||
this.waiting.splice(0).forEach((waiter) => waiter.reject(error))
|
||||
}
|
||||
|
||||
take() {
|
||||
const value = this.values.shift()
|
||||
if (value) return Promise.resolve(value)
|
||||
if (this.error) return Promise.reject(this.error)
|
||||
return new Promise<Value>((resolve, reject) => this.waiting.push({ resolve, reject }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
|
||||
|
||||
describe("session sync engine laws", () => {
|
||||
test("1. idempotency: lost responses converge to one admitted message", async () => {
|
||||
const server = new FakeSessionServer("session-idempotency")
|
||||
server.faults.loseResponses = 1
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
now: () => server.time,
|
||||
reconnect: async () => {},
|
||||
})
|
||||
|
||||
engine.submit({ id: "msg_1", text: "hello" })
|
||||
await until(() => engine.view().seq === 1)
|
||||
server.cutConnections()
|
||||
await engine.settled()
|
||||
|
||||
expect(server.admitted).toEqual(["msg_1"])
|
||||
expect(userMessages(engine.view().messages)).toHaveLength(1)
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("2. echo determinism: folding the echo does not change rendered messages", async () => {
|
||||
const server = new FakeSessionServer("session-echo")
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, { now: () => server.time })
|
||||
await until(() => server.admitted.length === 0 && engine.view().seq === 0)
|
||||
|
||||
engine.submit({ id: "msg_1", text: "instant" })
|
||||
const before = engine.view().messages
|
||||
await engine.settled()
|
||||
|
||||
expect(engine.view().messages).toEqual(before)
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("3. sync opacity: the fold cannot see intents or the engine", () => {
|
||||
const source = readFileSync(new URL("../src/solid/engine/fold.ts", import.meta.url), "utf8")
|
||||
const code = source
|
||||
.split("\n")
|
||||
.filter((line) => !line.trim().startsWith("//") && !line.trim().startsWith("*"))
|
||||
.join("\n")
|
||||
|
||||
expect(code).not.toContain("outbox")
|
||||
expect(code).not.toContain("./engine")
|
||||
expect(code).not.toContain("intent")
|
||||
})
|
||||
|
||||
test("4. ordering: a burst admits in submission order", async () => {
|
||||
const server = new FakeSessionServer("session-ordering")
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server)
|
||||
|
||||
for (const value of [1, 2, 3, 4, 5]) engine.submit({ id: `msg_${value}`, text: `m${value}` })
|
||||
await engine.settled()
|
||||
|
||||
expect(server.admitted).toEqual(["msg_1", "msg_2", "msg_3", "msg_4", "msg_5"])
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("5. convergence: drained clients equal the server fold", async () => {
|
||||
const server = new FakeSessionServer("session-convergence")
|
||||
const a = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_a" })
|
||||
const b = await Engine.createSessionEngine(server.sessionID, server, { makeID: () => "msg_b" })
|
||||
|
||||
a.submit({ text: "from a" })
|
||||
b.submit({ text: "from b" })
|
||||
await Promise.all([a.settled(), b.settled()])
|
||||
await until(() => a.view().seq === server.events.length && b.view().seq === server.events.length)
|
||||
|
||||
expect(a.view()).toEqual(server.truth())
|
||||
expect(b.view()).toEqual(server.truth())
|
||||
a.stop()
|
||||
b.stop()
|
||||
})
|
||||
|
||||
test("6. failure atomicity: typed rejection removes and surfaces the intent", async () => {
|
||||
const server = new FakeSessionServer("session-failure")
|
||||
server.faults.reject = 1
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server)
|
||||
const failures: Array<Engine.IntentFailure> = []
|
||||
engine.subscribeFailures((failure) => failures.push(failure))
|
||||
const before = engine.view()
|
||||
|
||||
const intent = engine.submit({ id: "msg_1", text: "doomed" })
|
||||
expect(userMessages(engine.view().messages)).toHaveLength(1)
|
||||
await until(() => failures.length === 1)
|
||||
|
||||
expect(engine.view()).toEqual(before)
|
||||
expect(failures).toEqual([{ intent, reason: "rejected" }])
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("7. lossy history: reconnect without retained events recovers via snapshot", async () => {
|
||||
const server = new FakeSessionServer("session-lossy")
|
||||
let release: (() => void) | undefined
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
now: () => server.time,
|
||||
reconnect: () => new Promise<void>((resolve) => (release = resolve)),
|
||||
})
|
||||
engine.submit({ id: "msg_1", text: "first" })
|
||||
await engine.settled()
|
||||
|
||||
server.cutConnections()
|
||||
await until(() => release !== undefined)
|
||||
// While disconnected the session advances, then history is dropped: the
|
||||
// reconnect cursor cannot be replayed and must recover via snapshot.
|
||||
await server.submit({ id: "msg_2", sessionID: server.sessionID, request: { text: "second" } })
|
||||
server.prune()
|
||||
release!()
|
||||
|
||||
await until(() => engine.view().seq === 2)
|
||||
expect(engine.view()).toEqual(server.truth())
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("8. attach gaps: a synced marker past the fold forces snapshot recovery", async () => {
|
||||
const server = new FakeSessionServer("session-marker-gap")
|
||||
await server.submit({ id: "msg_1", sessionID: server.sessionID, request: { text: "hello" } })
|
||||
const stale = { ...server.snapshotValue(), messages: [], inbox: [], seq: 0 }
|
||||
let attempts = 0
|
||||
const engine = await Engine.createSessionEngine(
|
||||
server.sessionID,
|
||||
{
|
||||
snapshot: (sessionID) => (attempts === 0 ? Promise.resolve(stale) : server.snapshot(sessionID)),
|
||||
async *stream(sessionID, after, signal) {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
// Dishonest attach: the marker admits the cursor but skips the replay range.
|
||||
yield { type: "log.synced" as const, aggregateID: sessionID, seq: server.snapshotValue().seq }
|
||||
return
|
||||
}
|
||||
yield* server.stream(sessionID, after, signal)
|
||||
},
|
||||
submit: (input) => server.submit(input),
|
||||
},
|
||||
{ reconnect: async () => {} },
|
||||
)
|
||||
await engine.ready()
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
expect(engine.view()).toEqual(server.truth())
|
||||
engine.stop()
|
||||
})
|
||||
|
||||
test("snapshot refresh cannot move the fold behind the live log", async () => {
|
||||
const server = new FakeSessionServer("session-refresh-race")
|
||||
const stale = server.snapshotValue()
|
||||
let refresh = false
|
||||
const transport: Engine.SessionTransport = {
|
||||
snapshot: (sessionID) => (refresh ? Promise.resolve(stale) : server.snapshot(sessionID)),
|
||||
stream: (sessionID, after) => server.stream(sessionID, after),
|
||||
submit: (input) => server.submit(input),
|
||||
}
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, transport)
|
||||
await engine.ready()
|
||||
|
||||
engine.submit({ id: "msg_1", text: "newer than snapshot" })
|
||||
await until(() => engine.view().seq === 1)
|
||||
refresh = true
|
||||
await engine.refresh()
|
||||
|
||||
expect(engine.view().seq).toBe(1)
|
||||
expect(engine.view().pending.map((item) => item.id)).toEqual(["msg_1"])
|
||||
engine.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Engine } from "../src/solid/engine/engine"
|
||||
import { FakeSessionServer, until, userMessages } from "./fixture/sync-engine"
|
||||
|
||||
type Client = {
|
||||
readonly name: string
|
||||
readonly engine: Engine.SessionEngine
|
||||
readonly submitted: Array<string>
|
||||
readonly rejected: Set<string>
|
||||
readonly views: Array<Engine.SessionView>
|
||||
}
|
||||
|
||||
describe("session sync engine simulation", () => {
|
||||
for (const seed of [1, 2, 3, 42, 1337, 90210]) {
|
||||
test(`seed ${seed}: two clients converge through chaotic transport faults`, async () => {
|
||||
const random = mulberry32(seed)
|
||||
const server = new FakeSessionServer(`session-sim-${seed}`)
|
||||
const clients = await Promise.all([makeClient("a", server), makeClient("b", server)])
|
||||
|
||||
for (let step = 0; step < 80; step++) {
|
||||
const roll = random()
|
||||
if (roll < 0.45) {
|
||||
const client = pick(clients, random)
|
||||
const intent = client.engine.submit({ text: `step-${step}` })
|
||||
client.submitted.push(intent.id)
|
||||
} else if (roll < 0.55) {
|
||||
server.cutConnections()
|
||||
} else if (roll < 0.65) {
|
||||
server.faults.loseResponses++
|
||||
} else if (roll < 0.73) {
|
||||
server.faults.loseRequests += 1 + Math.floor(random() * 2)
|
||||
} else if (roll < 0.8) {
|
||||
server.faults.reject++
|
||||
} else {
|
||||
server.faults.latency = Math.floor(random() * 6)
|
||||
}
|
||||
await advance(2 + Math.floor(random() * 8))
|
||||
}
|
||||
|
||||
server.faults.latency = 0
|
||||
server.faults.loseRequests = 0
|
||||
server.faults.loseResponses = 0
|
||||
server.faults.reject = 0
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
server.cutConnections()
|
||||
await advance(4)
|
||||
const accounted = clients.every(
|
||||
(client) =>
|
||||
client.submitted.filter((id) => server.admitted.includes(id) || client.rejected.has(id)).length ===
|
||||
client.submitted.length,
|
||||
)
|
||||
if (accounted) break
|
||||
}
|
||||
await until(
|
||||
() => clients.every((client) => client.engine.view().seq === server.events.length),
|
||||
`seed ${seed} did not converge`,
|
||||
)
|
||||
|
||||
expect(new Set(server.admitted).size).toBe(server.admitted.length)
|
||||
for (const client of clients) {
|
||||
const expected = client.submitted.filter((id) => !client.rejected.has(id))
|
||||
const observed = server.admitted.filter((id) => client.submitted.includes(id))
|
||||
expect(observed).toEqual(expected)
|
||||
expect(client.engine.view()).toEqual(server.truth())
|
||||
assertNoFlicker(client.views, server.admitted, `${seed}/${client.name}`)
|
||||
client.engine.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function makeClient(name: string, server: FakeSessionServer): Promise<Client> {
|
||||
let counter = 0
|
||||
const engine = await Engine.createSessionEngine(server.sessionID, server, {
|
||||
makeID: () => `msg_${name}${String(++counter).padStart(4, "0")}`,
|
||||
now: () => server.time,
|
||||
reconnect: async () => {},
|
||||
})
|
||||
const client: Client = { name, engine, submitted: [], rejected: new Set(), views: [engine.view()] }
|
||||
engine.subscribe((view) => client.views.push(view))
|
||||
engine.subscribeFailures((failure) => client.rejected.add(failure.intent.id))
|
||||
return client
|
||||
}
|
||||
|
||||
function assertNoFlicker(views: ReadonlyArray<Engine.SessionView>, admitted: ReadonlyArray<string>, label: string) {
|
||||
for (const id of admitted) {
|
||||
const first = views.findIndex((view) => userMessages(view.messages).some((message) => message.id === id))
|
||||
expect(first, `${label}: ${id} never rendered`).toBeGreaterThanOrEqual(0)
|
||||
for (const view of views.slice(first)) {
|
||||
const rows = userMessages(view.messages).filter((message) => message.id === id)
|
||||
expect(rows, `${label}: ${id} disappeared or duplicated`).toHaveLength(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function advance(steps: number) {
|
||||
for (let step = 0; step < steps; step++) await Promise.resolve()
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
|
||||
function pick<Value>(values: ReadonlyArray<Value>, random: () => number) {
|
||||
return values[Math.floor(random() * values.length)]!
|
||||
}
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
return () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
const first = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
const second = (first + Math.imul(first ^ (first >>> 7), 61 | first)) ^ first
|
||||
return ((second ^ (second >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,21 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const retainedCount = Effect.fn("Bus.retainedCount")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
after: number,
|
||||
through: number,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after), lte(EventTable.seq, through)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.count ?? 0
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: Event.ID
|
||||
readonly type: string
|
||||
@@ -150,6 +165,7 @@ export interface Interface {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
@@ -768,6 +784,7 @@ export function configured(options?: Options) {
|
||||
readonly aggregateID: string
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
readonly includeLive?: (event: Event.Payload) => boolean
|
||||
}): Stream.Stream<LogItem> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -791,7 +808,8 @@ export function configured(options?: Options) {
|
||||
)
|
||||
// Subscribing before the historical read means events committed during
|
||||
// replay either appear in the read or arrive through a post-marker wake.
|
||||
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const subscription = input.follow && input.includeLive ? yield* PubSub.subscribe(pubsub.live) : undefined
|
||||
const wakes = input.follow && !subscription ? yield* subscribeDurable(input.aggregateID) : undefined
|
||||
const target = yield* latestSequence(db, input.aggregateID)
|
||||
const marker: EventLog.Synced = {
|
||||
type: "log.synced",
|
||||
@@ -802,6 +820,14 @@ export function configured(options?: Options) {
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
if (subscription && input.includeLive) {
|
||||
const follow: Stream.Stream<LogItem> = Stream.fromSubscription(subscription).pipe(
|
||||
Stream.filter(input.includeLive),
|
||||
Stream.filter((event) => !event.durable || event.durable.seq > target),
|
||||
Stream.map((event): LogItem => event),
|
||||
)
|
||||
return Stream.concat(replay, follow)
|
||||
}
|
||||
if (!wakes) return replay
|
||||
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
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 }),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -4,15 +4,21 @@ 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 { make, type Info } from "./formatter/builtins.js"
|
||||
import type { Info } from "./formatter/builtins.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export interface Interface {
|
||||
type Data = {
|
||||
formatters: Info[]
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
set: (formatter: Info) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -21,66 +27,36 @@ 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 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 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 command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
const cached = commands.get(formatter)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
if (result !== false) commands.set(formatter, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
const matching = state
|
||||
.get()
|
||||
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
@@ -118,12 +94,12 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ file })
|
||||
return Service.of({ transform: state.transform, reload: state.reload, file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node],
|
||||
deps: [Location.node, AppProcess.node],
|
||||
})
|
||||
|
||||
+33
-17
@@ -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,7 +32,18 @@ export class SizeError extends Schema.TaggedError<SizeError>()("Image.SizeError"
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
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> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -47,7 +58,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
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 loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon.js"),
|
||||
@@ -58,22 +85,11 @@ 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, {
|
||||
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 yield* normalize(resource, content, state.get())
|
||||
})
|
||||
return Service.of({ normalize })
|
||||
return Service.of({ transform: state.transform, reload: state.reload, normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import type { ModelHookOptions } from "@opencode-ai/plugin/effect/registration"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state.js"
|
||||
@@ -26,16 +27,26 @@ interface Failures extends Record<keyof Domains, unknown> {
|
||||
}
|
||||
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
type Entry = { readonly callback: Function; readonly options?: ModelHookOptions }
|
||||
|
||||
const eventProviderID = (event: unknown) => {
|
||||
if (typeof event !== "object" || event === null || !("model" in event)) return undefined
|
||||
const model = event.model
|
||||
if (typeof model !== "object" || model === null || !("providerID" in model)) return undefined
|
||||
return typeof model.providerID === "string" ? model.providerID : undefined
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly has: <Domain extends keyof Domains>(
|
||||
domain: Domain,
|
||||
name: keyof Domains[Domain] & keyof Failures[Domain],
|
||||
providerID?: string,
|
||||
) => Effect.Effect<boolean>
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||
options?: ModelHookOptions,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
@@ -49,36 +60,47 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pl
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const callbacks = new Map<string, Function[]>()
|
||||
const callbacks = new Map<string, Entry[]>()
|
||||
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
|
||||
|
||||
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
|
||||
const scope = yield* Scope.Scope
|
||||
const id = key(domain, name)
|
||||
let active = true
|
||||
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
|
||||
if (next.length === 0) callbacks.delete(id)
|
||||
else callbacks.set(id, next)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
const register: Interface["register"] = Effect.fn("PluginHooks.register")(
|
||||
function* (domain, name, callback, options) {
|
||||
const scope = yield* Scope.Scope
|
||||
const id = key(domain, name)
|
||||
let active = true
|
||||
const entry = { callback, options }
|
||||
callbacks.set(id, [...(callbacks.get(id) ?? []), entry])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
const next = (callbacks.get(id) ?? []).filter((item) => item !== entry)
|
||||
if (next.length === 0) callbacks.delete(id)
|
||||
else callbacks.set(id, next)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
},
|
||||
)
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fnUntraced(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,
|
||||
])
|
||||
for (const entry of callbacks.get(key(domain, name)) ?? []) {
|
||||
if (entry.options?.providerID !== undefined && entry.options.providerID !== eventProviderID(event)) continue
|
||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(
|
||||
entry.callback,
|
||||
undefined,
|
||||
[event],
|
||||
)
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
|
||||
const has: Interface["has"] = (domain, name, providerID) =>
|
||||
Effect.sync(() =>
|
||||
(callbacks.get(key(domain, name)) ?? []).some(
|
||||
(entry) => entry.options?.providerID === undefined || entry.options.providerID === providerID,
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ has, register, trigger })
|
||||
}),
|
||||
|
||||
@@ -104,9 +104,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback) => {
|
||||
hook: (name, callback, options) => {
|
||||
if (name === "sdk") {
|
||||
return aisdk.hook.sdk((event) => {
|
||||
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
|
||||
const output = {
|
||||
model: mutable(event.model),
|
||||
package: event.package,
|
||||
@@ -119,6 +120,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}
|
||||
return aisdk.hook.language((event) => {
|
||||
if (options?.providerID !== undefined && options.providerID !== event.model.providerID) return Effect.void
|
||||
const output = {
|
||||
model: mutable(event.model),
|
||||
options: event.options,
|
||||
@@ -382,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as PluginInternal from "./internal.js"
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -12,6 +13,8 @@ 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"
|
||||
@@ -77,6 +80,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js"
|
||||
|
||||
const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const command = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
@@ -115,6 +119,7 @@ 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),
|
||||
@@ -160,6 +165,7 @@ export type Requirements = ContextServices<Effect.Success<ReturnType<typeof serv
|
||||
|
||||
export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
AppProcess.node,
|
||||
Catalog.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
@@ -232,6 +238,8 @@ const post = [
|
||||
ConfigReferencePlugin.Plugin,
|
||||
ConfigAgentPlugin.Plugin,
|
||||
ConfigCommandPlugin.Plugin,
|
||||
ConfigFormatterPlugin.Plugin,
|
||||
ConfigImagePlugin.Plugin,
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
|
||||
@@ -241,19 +241,22 @@ export const GithubCopilotPlugin = define({
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
const body = Option.getOrUndefined(decodeBody(text))
|
||||
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
|
||||
}),
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (evt.agent === Agent.ID.make("title"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-background")
|
||||
if (evt.agent === Agent.ID.make("compaction"))
|
||||
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
|
||||
const token = evt.request.headers.get("x-api-key")
|
||||
if (!token) return
|
||||
const text = yield* Effect.promise(() => evt.request.clone().text())
|
||||
const body = Option.getOrUndefined(decodeBody(text))
|
||||
applyHeaders(evt.request.headers, token, ctx.app, requestMetadata(evt.request.url, body), true)
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
|
||||
@@ -26,7 +26,6 @@ function resolveLocation(options: Record<string, any>) {
|
||||
|
||||
function vertexEndpoint(location: string) {
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
@@ -115,18 +114,13 @@ export const GoogleVertexPlugin = define({
|
||||
if (evt.package !== "@ai-sdk/google-vertex") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex"))
|
||||
const project = resolveProject(evt.options)
|
||||
const location = String(resolveLocation(evt.options))
|
||||
const location = resolveLocation(evt.options)
|
||||
const options = { ...evt.options }
|
||||
delete options.fetch
|
||||
evt.sdk = mod.createVertex({
|
||||
...options,
|
||||
project,
|
||||
location,
|
||||
...((location === "eu" || location === "us") && project && !options.apiKey && !options.baseURL
|
||||
? {
|
||||
baseURL: `https://${vertexEndpoint(location)}/v1beta1/projects/${project}/locations/${location}/publishers/google`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
@@ -230,15 +229,17 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
}),
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
(evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt) return
|
||||
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
|
||||
evt.baseURL = codexBaseURL
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
}),
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
* imports: the supervisor reaches PluginRuntime, which depends on Session.
|
||||
*/
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
@@ -1,8 +1,9 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Context, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -14,6 +15,7 @@ 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([
|
||||
@@ -128,13 +130,6 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
} 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* () {
|
||||
|
||||
+118
-16
@@ -40,6 +40,7 @@ import { SessionRevert } from "./session/revert.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Image } from "./image.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor-service.js"
|
||||
import { Mime } from "./mime.js"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
@@ -140,6 +141,11 @@ export class InboxConflictError extends Schema.TaggedError<InboxConflictError>()
|
||||
sessionID: SessionSchema.ID,
|
||||
inboxID: SessionMessage.ID,
|
||||
}) {}
|
||||
export class SeqUnavailableError extends Schema.TaggedError<SeqUnavailableError>()("Session.SeqUnavailableError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
after: Event.Seq,
|
||||
head: Schema.optional(Event.Seq),
|
||||
}) {}
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
export class SkillNotFoundError extends Schema.TaggedError<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: Skill.ID,
|
||||
@@ -193,13 +199,33 @@ export interface Interface {
|
||||
* unhandled compaction barriers.
|
||||
*/
|
||||
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
|
||||
readonly snapshot: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
recent?: number
|
||||
}) => Effect.Effect<
|
||||
{
|
||||
readonly session: SessionSchema.Info
|
||||
readonly children: SessionSchema.Info[]
|
||||
readonly inbox: SessionInbox.Info[]
|
||||
readonly messages: SessionMessage.Info[]
|
||||
readonly seq: Event.Seq
|
||||
},
|
||||
NotFoundError | MessageDecodeError
|
||||
>
|
||||
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
|
||||
readonly openLog: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
ephemeral?: boolean
|
||||
}) => Effect.Effect<Stream.Stream<SessionEvent.Event | EventLog.Synced>, NotFoundError | SeqUnavailableError>
|
||||
/**
|
||||
* Durable, ordered session log read. Replays durable session bus after
|
||||
* the exclusive `after` cursor, emits a `Synced` marker at the captured
|
||||
* replay watermark, then continues live when `follow` is set.
|
||||
* Ordered session log read. Replays durable session events after the
|
||||
* exclusive `after` cursor, emits a `Synced` marker at the captured replay
|
||||
* watermark, then continues live when `follow` is set. Ephemeral events are
|
||||
* included only in the live phase when explicitly requested.
|
||||
* The marker's seq may exceed the last emitted event because other durable
|
||||
* bus share the aggregate's sequence space.
|
||||
*/
|
||||
@@ -207,7 +233,8 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
after?: number
|
||||
follow?: boolean
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
ephemeral?: boolean
|
||||
}) => Stream.Stream<SessionEvent.Event | EventLog.Synced, NotFoundError | SeqUnavailableError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
@@ -325,6 +352,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const isSessionEvent = Schema.is(SessionEvent.All)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
@@ -556,20 +584,90 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* SessionInbox.list(db, sessionID)
|
||||
}),
|
||||
snapshot: Effect.fn("Session.snapshot")(function* (input) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
const children = yield* db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.parent_id, input.sessionID))
|
||||
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inbox = yield* SessionInbox.list(db, input.sessionID)
|
||||
const messages = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, input.sessionID))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(input.recent ?? 200)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const seq = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (seq < 0) return yield* Effect.die(new Error(`Session ${input.sessionID} has no event sequence`))
|
||||
return {
|
||||
session: fromRow(row),
|
||||
children: children.map(fromRow),
|
||||
inbox,
|
||||
messages: yield* Effect.forEach(messages.toReversed(), decode),
|
||||
seq: Event.Seq.make(seq),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.catchTag("SqlError", Effect.die))
|
||||
}),
|
||||
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
|
||||
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
|
||||
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
|
||||
log: (input) =>
|
||||
Stream.unwrap(
|
||||
result
|
||||
.get(input.sessionID)
|
||||
.pipe(Effect.as(bus.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
|
||||
).pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.DurableEvent | EventLog.Synced =>
|
||||
Bus.isSynced(item) || isDurableSessionEvent(item),
|
||||
),
|
||||
),
|
||||
openLog: Effect.fn("Session.openLog")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
if (input.after !== undefined) {
|
||||
const head = yield* Bus.latestSequence(db, input.sessionID)
|
||||
if (input.after > head)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: head >= 0 ? Event.Seq.make(head) : undefined,
|
||||
})
|
||||
// A cursor claims the caller already holds everything through `after`, so
|
||||
// replay of (after, head] must be provably complete. Without retained rows
|
||||
// covering the range (events.persist off, or pruned history) replaying
|
||||
// nothing would silently desync the caller; fail so it re-snapshots instead.
|
||||
if (input.after < head) {
|
||||
const retained = yield* Bus.retainedCount(db, input.sessionID, input.after, head)
|
||||
if (retained < head - input.after)
|
||||
return yield* new SeqUnavailableError({
|
||||
sessionID: input.sessionID,
|
||||
after: Event.Seq.make(input.after),
|
||||
head: Event.Seq.make(head),
|
||||
})
|
||||
}
|
||||
}
|
||||
return bus
|
||||
.log({
|
||||
aggregateID: input.sessionID,
|
||||
after: input.after,
|
||||
follow: input.follow,
|
||||
includeLive: input.ephemeral
|
||||
? (event) => isSessionEvent(event) && event.data.sessionID === input.sessionID
|
||||
: undefined,
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter(
|
||||
(item): item is SessionEvent.Event | EventLog.Synced =>
|
||||
Bus.isSynced(item) || (input.ephemeral ? isSessionEvent(item) : isDurableSessionEvent(item)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
log: (input) => Stream.unwrap(result.openLog(input)),
|
||||
prompt: Effect.fn("Session.prompt")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
@@ -579,7 +677,11 @@ const layer = Layer.effect(
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
// Resolved lazily so prompt admission only boots location services when an
|
||||
// image attachment actually needs the resizer.
|
||||
const image = Image.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const image = Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const prompt = yield* resolvePrompt(
|
||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
|
||||
@@ -12,6 +12,7 @@ import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { App } from "../app.js"
|
||||
@@ -270,23 +271,25 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.ref },
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
}),
|
||||
)
|
||||
yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: plan.model,
|
||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
|
||||
import { SessionGenerate } from "./generate.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
@@ -71,7 +72,9 @@ export const layer = Layer.effect(
|
||||
providerID: model.ref.providerID,
|
||||
modelID: model.ref.id,
|
||||
})
|
||||
const response = yield* llm.generate(
|
||||
const request = yield* SessionModelHook.apply(
|
||||
hooks,
|
||||
{ sessionID: selection.session.id, agent: selection.agent.id, model: model.ref },
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
@@ -80,14 +83,14 @@ export const layer = Layer.effect(
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const response = yield* llm.generate(request, {
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
}),
|
||||
})
|
||||
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
|
||||
return response.text
|
||||
}),
|
||||
|
||||
@@ -180,13 +180,27 @@ export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(functio
|
||||
bus: Bus.Interface,
|
||||
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
|
||||
) {
|
||||
const admitted = yield* admit(db, bus, {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
|
||||
})
|
||||
if (admitted.type === "compaction") return admitted
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return yield* serialized(
|
||||
input.sessionID,
|
||||
Effect.gen(function* () {
|
||||
const exact = yield* find(db, input.id)
|
||||
if (exact) {
|
||||
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
}
|
||||
if (yield* promotedFromMessage(db, input.sessionID, input.id, input.delivery))
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const pending = (yield* list(db, input.sessionID)).find((item) => item.type === "compaction")
|
||||
if (pending) return pending
|
||||
const admitted = yield* admit(db, bus, {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
|
||||
})
|
||||
if (admitted.type === "compaction") return admitted
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export * as SessionModelHook from "./model-hook.js"
|
||||
|
||||
import { HttpOptions, LanguageModel, LLMRequest } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect } from "effect"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
|
||||
export const apply = (
|
||||
hooks: PluginHooks.Interface,
|
||||
input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref },
|
||||
request: LLMRequest,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const currentBaseURL = request.model.route.endpoint.baseURL
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
...input,
|
||||
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
|
||||
headers: { ...request.http?.headers },
|
||||
})
|
||||
const route =
|
||||
event.baseURL !== undefined && event.baseURL !== currentBaseURL
|
||||
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
|
||||
: request.model.route
|
||||
return LLMRequest.update(request, {
|
||||
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
|
||||
http: new HttpOptions({
|
||||
body: request.http?.body,
|
||||
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
|
||||
query: request.http?.query,
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
@@ -226,20 +227,25 @@ export const layer = Layer.effect(
|
||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const request = yield* SessionModelHook.apply(
|
||||
hooks,
|
||||
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||
LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: SessionPromptCacheKey.make(session.fork?.sessionID ?? session.id),
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
}),
|
||||
)
|
||||
const webSocketEligible =
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
!(yield* hooks.has("session", "http.request", resolved.ref.providerID)) &&
|
||||
!(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
const http = webSocketEligible
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
@@ -252,7 +258,7 @@ export const layer = Layer.effect(
|
||||
...(webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
model.route.id === "openai-responses"
|
||||
request.model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
@@ -80,23 +81,25 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
}),
|
||||
)
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
{
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
},
|
||||
)
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { 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"))
|
||||
})
|
||||
@@ -1,41 +1,30 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Config } from "../src/config"
|
||||
import { Info } from "@opencode-ai/schema/config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[Config.node, Config.testLayer(entries)],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -44,122 +33,208 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
||||
)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.disabled")
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
disabled: {
|
||||
disabled: true,
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".disabled"],
|
||||
},
|
||||
}),
|
||||
function withFormatter<A, E, R>(
|
||||
configured: ConfigInput["formatter"],
|
||||
body: (formatter: Formatter.Interface, directory: string) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return withTemp((directory) =>
|
||||
Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* body(yield* Formatter.Service, directory)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withFormatter(
|
||||
{
|
||||
disabled: {
|
||||
disabled: true,
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".disabled"],
|
||||
},
|
||||
},
|
||||
(formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.disabled")
|
||||
expect(yield* formatter.file(file)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("file() returns false when no formatter runs", () =>
|
||||
withTemp((directory) =>
|
||||
withFormatter(false, (formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
expect(yield* formatter.file(file)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads formatter state per directory", () =>
|
||||
withTemp((off) =>
|
||||
withTemp((on) =>
|
||||
Effect.gen(function* () {
|
||||
const offFile = path.join(off, "test.isolated")
|
||||
const onFile = path.join(on, "test.isolated")
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
||||
Effect.provide(formatterLayer(off, false)),
|
||||
)
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(on, {
|
||||
isolated: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".isolated"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(disabled).toBe(false)
|
||||
expect(enabled).toBe(true)
|
||||
}),
|
||||
withFormatter(false, (disabledFormatter, off) =>
|
||||
withFormatter(
|
||||
{
|
||||
isolated: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".isolated"],
|
||||
},
|
||||
},
|
||||
(enabledFormatter, on) =>
|
||||
Effect.gen(function* () {
|
||||
const offFile = path.join(off, "test.isolated")
|
||||
const onFile = path.join(on, "test.isolated")
|
||||
const disabled = yield* disabledFormatter.file(offFile)
|
||||
const enabled = yield* enabledFormatter.file(onFile)
|
||||
expect(disabled).toBe(false)
|
||||
expect(enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stops after the first matching formatter succeeds", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.seq")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
withFormatter(
|
||||
{
|
||||
first: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
},
|
||||
(formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.seq")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("tries the next matching formatter when the first fails", () =>
|
||||
withTemp((directory) =>
|
||||
withFormatter(
|
||||
{
|
||||
first: {
|
||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
},
|
||||
(formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.fallback")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rebuilds formatter state and clears resolved commands", () =>
|
||||
withFormatter(false, (formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.fallback")
|
||||
const command = { suffix: "A" }
|
||||
yield* formatter.transform((draft) => {
|
||||
const suffix = command.suffix
|
||||
draft.set({
|
||||
name: "reload",
|
||||
extensions: [".reload"],
|
||||
enabled: Effect.succeed([
|
||||
process.execPath,
|
||||
"-e",
|
||||
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
|
||||
"$FILE",
|
||||
]),
|
||||
})
|
||||
})
|
||||
const file = path.join(directory, "test.reload")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
|
||||
command.suffix = "B"
|
||||
yield* formatter.reload()
|
||||
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not cache a command resolved before reload", () =>
|
||||
withFormatter(false, (formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
const resolving = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const command = { suffix: "A" }
|
||||
yield* formatter.transform((draft) => {
|
||||
const suffix = command.suffix
|
||||
const resolved = [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`,
|
||||
"$FILE",
|
||||
]
|
||||
draft.set({
|
||||
name: "reload-race",
|
||||
extensions: [".race"],
|
||||
enabled:
|
||||
suffix === "A"
|
||||
? Deferred.succeed(resolving, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as(resolved),
|
||||
)
|
||||
: Effect.succeed(resolved),
|
||||
})
|
||||
})
|
||||
const file = path.join(directory, "test.race")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
const first = yield* formatter.file(file).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(resolving)
|
||||
|
||||
command.suffix = "B"
|
||||
yield* formatter.reload()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* Fiber.join(first)).toBe(true)
|
||||
|
||||
expect(yield* formatter.file(file)).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -320,10 +320,14 @@ describe("fromPromise", () => {
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http.request", (event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
})
|
||||
await ctx.session.hook(
|
||||
"http.request",
|
||||
(event) => {
|
||||
event.request = new Request("https://provider.test/changed", event.request)
|
||||
event.request.headers.set("x-hook", "promise")
|
||||
},
|
||||
{ providerID: "test" },
|
||||
)
|
||||
await ctx.session.hook("http.response", async (event) => {
|
||||
event.response = new Response(`${await event.response.text()}-response`, {
|
||||
status: event.response.status,
|
||||
@@ -342,6 +346,11 @@ describe("fromPromise", () => {
|
||||
...context,
|
||||
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||
})
|
||||
const ignored = yield* hooks.trigger("session", "http.request", {
|
||||
...context,
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("other"), id: Model.ID.make("model") }),
|
||||
request: new Request("https://other.test"),
|
||||
})
|
||||
const response = yield* hooks.trigger("session", "http.response", {
|
||||
...context,
|
||||
request: request.request,
|
||||
@@ -349,6 +358,9 @@ describe("fromPromise", () => {
|
||||
})
|
||||
|
||||
expect(request.request.url).toBe("https://provider.test/changed")
|
||||
expect(ignored.request.url).toBe("https://other.test/")
|
||||
expect(yield* hooks.has("session", "http.request", Provider.ID.make("test"))).toBe(true)
|
||||
expect(yield* hooks.has("session", "http.request", Provider.ID.make("other"))).toBe(false)
|
||||
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -276,7 +276,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the REP endpoint for OpenAI-compatible Vertex multi-regions", () =>
|
||||
it.effect("keeps OpenAI-compatible Vertex endpoint templates regional for eu", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
@@ -294,65 +294,11 @@ describe("GoogleVertexPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.make("google-vertex")))
|
||||
expect(provider).toMatchObject({
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: {
|
||||
baseURL: "https://aiplatform.eu.rep.googleapis.com/v1/projects/config-project/locations/eu",
|
||||
},
|
||||
settings: { baseURL: "https://eu-aiplatform.googleapis.com/v1/projects/config-project/locations/eu" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the REP endpoint for the native Vertex SDK in multi-regions", () =>
|
||||
withEnv(
|
||||
{
|
||||
GOOGLE_CLOUD_PROJECT: "env-project",
|
||||
GOOGLE_VERTEX_LOCATION: "us",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: { name: "google-vertex" },
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1beta1/projects/env-project/locations/us/publishers/google",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves custom native Vertex base URLs in multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
vertexOptions.length = 0
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("google-vertex"), Model.ID.make("gemini")),
|
||||
modelID: Model.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
package: "@ai-sdk/google-vertex",
|
||||
options: {
|
||||
name: "google-vertex",
|
||||
project: "project",
|
||||
location: "eu",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
},
|
||||
})
|
||||
expect(vertexOptions).toHaveLength(1)
|
||||
expect(vertexOptions[0].baseURL).toBe("https://vertex.example/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults location to us-central1 when only project is configured", () =>
|
||||
withEnv(
|
||||
{
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -24,19 +32,33 @@ const addPlugin = Effect.fn(function* () {
|
||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||
})
|
||||
|
||||
const addGithubCopilotPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GithubCopilotPlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: new Request(url, { method: "POST", body: "{}" }),
|
||||
baseURL,
|
||||
headers: {},
|
||||
})
|
||||
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||
return {
|
||||
baseURL: event.baseURL,
|
||||
headers: event.headers,
|
||||
hasHttpHooks:
|
||||
(yield* hooks.has("session", "http.request", providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", providerID)),
|
||||
}
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
@@ -110,18 +132,19 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
|
||||
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
|
||||
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
|
||||
const custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1")
|
||||
const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(direct.hasHttpHooks).toBe(false)
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
@@ -167,16 +190,77 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(request.headers).not.toHaveProperty("originator")
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.hasHttpHooks).toBe(false)
|
||||
expect(provider.headers).not.toHaveProperty("originator")
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects WebSocket with the built-in provider hooks enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
||||
})
|
||||
yield* addPlugin()
|
||||
yield* addGithubCopilotPlugin()
|
||||
const executor = { execute: () => Effect.die("unused WebSocket execution") }
|
||||
const transport = SessionModelTransport.Service.of({
|
||||
bind: () => executor,
|
||||
close: () => Effect.void,
|
||||
closeAll: Effect.void,
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_websocket_hooks")
|
||||
const agentID = Agent.ID.make("build")
|
||||
const agent = Agent.Info.make(Agent.Info.default(agentID))
|
||||
const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
context: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agent: { id: agentID, info: agent },
|
||||
model,
|
||||
initial: "",
|
||||
messages: [],
|
||||
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
|
||||
},
|
||||
step: 1,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, transport),
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const prepared = yield* program
|
||||
|
||||
expect(prepared.webSocketEligible).toBe(true)
|
||||
expect(prepared.options.webSocket).toBe(executor)
|
||||
expect(prepared.options.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("Session.compact", () => {
|
||||
it.effect("durably stacks manual compaction", () =>
|
||||
it.effect("durably coalesces manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const session = yield* Session.Service
|
||||
@@ -102,11 +102,10 @@ describe("Session.compact", () => {
|
||||
const first = yield* session.compact({ sessionID: created.id })
|
||||
const second = yield* session.compact({ sessionID: created.id })
|
||||
|
||||
expect(second.id).not.toBe(first.id)
|
||||
expect(second.id).toBe(first.id)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* session.inbox(created.id)).toEqual([
|
||||
expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
|
||||
expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
|
||||
])
|
||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||
|
||||
@@ -115,4 +114,20 @@ describe("Session.compact", () => {
|
||||
expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces concurrent manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
const admitted = yield* Effect.all(
|
||||
[SessionMessage.ID.create(), SessionMessage.ID.create()].map((id) =>
|
||||
session.compact({ id, sessionID: created.id }),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(admitted[1]?.id).toBe(admitted[0]?.id)
|
||||
expect(yield* session.inbox(created.id)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -291,18 +291,25 @@ it.effect(
|
||||
instruction = "Changed context"
|
||||
const before = yield* durableState(db, sessionID)
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let modelRequestHook = false
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system = [SystemPart.make("Hooked system"), ...event.system]
|
||||
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", () =>
|
||||
Effect.sync(() => {
|
||||
modelRequestHook = true
|
||||
}),
|
||||
)
|
||||
|
||||
const generate = yield* SessionGenerate.Service
|
||||
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
|
||||
|
||||
expect(result).toBe("Transient answer")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(modelRequestHook).toBe(true)
|
||||
expect(hasHttpMiddleware).toBe(true)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -15,6 +17,8 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
@@ -28,6 +32,16 @@ const it = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
// Default bus: durable payloads are not retained (`events.persist` off).
|
||||
const itVolatile = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.log", () => {
|
||||
@@ -60,6 +74,43 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a cursor exactly at the aggregate head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const items = Array.from(
|
||||
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(0) }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with SeqUnavailable when the cursor is beyond the aggregate head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const errors = yield* Effect.forEach([1, 10], (after) =>
|
||||
Effect.flip(Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(after) }))),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => error._tag)).toEqual([
|
||||
"Session.SeqUnavailableError",
|
||||
"Session.SeqUnavailableError",
|
||||
])
|
||||
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.after : undefined))).toEqual([
|
||||
Event.Seq.make(1),
|
||||
Event.Seq.make(10),
|
||||
])
|
||||
expect(errors.map((error) => (error._tag === "Session.SeqUnavailableError" ? error.head : undefined))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
Event.Seq.make(0),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with NotFound for an unknown session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -68,6 +119,109 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders live ephemeral deltas after their durable start", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const fiber = yield* session
|
||||
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true, ephemeral: true })
|
||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "hello",
|
||||
})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
|
||||
"log.synced",
|
||||
"session.text.started",
|
||||
"session.text.delta",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never includes ephemeral events in replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "not retained",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: "complete",
|
||||
})
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, ephemeral: true })))
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"session.created",
|
||||
"session.text.started",
|
||||
"session.text.ended",
|
||||
"log.synced",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the default follow stream durable-only", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
const fiber = yield* session
|
||||
.log({ sessionID: created.id, after: Event.Seq.make(0), follow: true })
|
||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: "filtered",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Text.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: "complete",
|
||||
})
|
||||
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((item) => item.type)).toEqual([
|
||||
"log.synced",
|
||||
"session.text.started",
|
||||
"session.text.ended",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
||||
Effect.gen(function* () {
|
||||
const GapEvent = Bus.durable({
|
||||
@@ -87,12 +241,33 @@ describe("Session.log", () => {
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
||||
|
||||
expect(
|
||||
items.map((item): number | string | undefined => (Bus.isSynced(item) ? item.type : item.durable?.seq)),
|
||||
items.map((item): number | string | undefined =>
|
||||
Bus.isSynced(item) ? item.type : "durable" in item ? item.durable.seq : undefined,
|
||||
),
|
||||
).toEqual([3, 4, "log.synced"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(4) })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with SeqUnavailable when the replay range is only partially retained", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "pruned" })
|
||||
yield* db
|
||||
.delete(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, created.id), eq(EventTable.seq, 1)))
|
||||
.run()
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(error._tag).toBe("Session.SeqUnavailableError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes with a bare synced marker for a migrated Session with no event sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -121,3 +296,46 @@ describe("Session.log", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Session.log without retained events", () => {
|
||||
itVolatile.effect("accepts a cursor exactly at the head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "at head" })
|
||||
|
||||
const items = Array.from(
|
||||
yield* Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(1) })),
|
||||
)
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
|
||||
}),
|
||||
)
|
||||
|
||||
itVolatile.effect("fails with SeqUnavailable for a cursor behind the head", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "behind head" })
|
||||
|
||||
const error = yield* Effect.flip(
|
||||
Stream.runCollect(session.log({ sessionID: created.id, after: Event.Seq.make(0) })),
|
||||
)
|
||||
|
||||
expect(error._tag).toBe("Session.SeqUnavailableError")
|
||||
expect(error._tag === "Session.SeqUnavailableError" ? error.head : undefined).toEqual(Event.Seq.make(1))
|
||||
}),
|
||||
)
|
||||
|
||||
itVolatile.effect("replays nothing but stays live for a cursorless read", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "cursorless" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
|
||||
expect(items).toEqual([{ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
@@ -59,12 +60,25 @@ const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
() =>
|
||||
// Attachment admission only needs the location-scoped Image service.
|
||||
// Attachment admission only needs image normalization and plugin readiness.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
|
||||
}) as unknown as Layer.Layer<LocationServices>,
|
||||
Layer.unwrap(
|
||||
Effect.sync(() => {
|
||||
let ready = false
|
||||
return Layer.mergeAll(
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
? Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content)
|
||||
: Effect.die(new Error("Image service used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
),
|
||||
)
|
||||
}),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
|
||||
@@ -1020,6 +1020,36 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps WebSocket eligibility after model request hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-model-request-hook"] = "active"
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "http.request", () => Effect.die("Other-provider HTTP hook should not apply"), {
|
||||
providerID: Provider.ID.githubCopilot,
|
||||
})
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const selected = yield* context.select(sessionID)
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID)
|
||||
|
||||
const prepared = yield* modelRequests.prepare({
|
||||
context: yield* context.load(selected),
|
||||
step: 1,
|
||||
})
|
||||
|
||||
expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active")
|
||||
expect(prepared.webSocketEligible).toBe(true)
|
||||
expect(prepared.options.http).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forces HTTP and triggers active request and response hooks once", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
|
||||
describe("Session.snapshot", () => {
|
||||
it.effect("returns an empty projected session at its aggregate watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
|
||||
expect(yield* sessions.snapshot({ sessionID: created.id })).toEqual({
|
||||
session: created,
|
||||
children: [],
|
||||
inbox: [],
|
||||
messages: [],
|
||||
seq: Event.Seq.make(0),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the most recent messages in aggregate order", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
yield* Effect.forEach(["first", "second", "third"], (text) =>
|
||||
bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text }),
|
||||
)
|
||||
|
||||
const snapshot = yield* sessions.snapshot({ sessionID: created.id, recent: 2 })
|
||||
|
||||
expect(snapshot.messages.map((message) => (message.type === "synthetic" ? message.text : message.type))).toEqual([
|
||||
"second",
|
||||
"third",
|
||||
])
|
||||
expect(snapshot.seq).toBe(Event.Seq.make(3))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps rows and watermark consistent during concurrent publication", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* sessions.create({ location })
|
||||
const publish = Effect.forEach(
|
||||
Array.from({ length: 40 }, (_, index) => index + 1),
|
||||
(index) => bus.publish(SessionEvent.Synthetic, { sessionID: created.id, text: String(index) }),
|
||||
)
|
||||
const read = Effect.forEach(Array.from({ length: 40 }), () => sessions.snapshot({ sessionID: created.id }))
|
||||
|
||||
const [, snapshots] = yield* Effect.all([publish, read], { concurrency: "unbounded" })
|
||||
|
||||
snapshots.forEach((snapshot) => {
|
||||
expect(snapshot.messages).toHaveLength(snapshot.seq)
|
||||
expect(
|
||||
snapshot.messages.map((message) => (message.type === "synthetic" ? Number(message.text) : -1)),
|
||||
).toEqual(Array.from({ length: snapshot.seq }, (_, index) => index + 1))
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,9 +1,7 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -90,7 +88,7 @@ const permission = permissionLayer({
|
||||
),
|
||||
})
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node)
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -130,10 +128,9 @@ const mutation = Layer.succeed(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const unavailableImage = Layer.mock(Image.Service, {
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
})
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [
|
||||
@@ -146,8 +143,9 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
]),
|
||||
// Merge by reference so Config.Test resolves to the memoized instance.
|
||||
// Merge by reference so Config.Test and Image.Service resolve to the memoized instances.
|
||||
config,
|
||||
imageLayer,
|
||||
)
|
||||
const it = testEffect(readLayer(imageLayer))
|
||||
const itWithoutResizer = testEffect(readLayer(unavailableImage))
|
||||
@@ -384,17 +382,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ autoResize: false, maxWidth: 4 }))
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
@@ -427,15 +416,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ maxWidth: 4 }))
|
||||
const registry = yield* Tool.Service
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
@@ -466,17 +448,8 @@ describe("ReadTool", () => {
|
||||
encoding: "base64",
|
||||
mime: "image/png",
|
||||
}
|
||||
const configTest = yield* Config.Test
|
||||
yield* configTest.setEntries([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const image = yield* Image.Service
|
||||
yield* image.transform((draft) => draft.configure({ maxBase64Bytes: 1 }))
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Hooks } from "./registration.js"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface AISDKHooks {
|
||||
sdk: {
|
||||
@@ -18,5 +18,5 @@ export interface AISDKHooks {
|
||||
}
|
||||
|
||||
export interface AISDKDomain {
|
||||
readonly hook: Hooks<AISDKHooks>
|
||||
readonly hook: ModelHooks<AISDKHooks>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ export interface Registration {
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface ModelHookOptions {
|
||||
/** Limits the hook to one provider. Unscoped hooks apply to every provider. */
|
||||
readonly providerID?: string
|
||||
}
|
||||
|
||||
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
|
||||
Name extends keyof Spec,
|
||||
>(
|
||||
@@ -11,4 +16,12 @@ export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<ke
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type ModelHooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
|
||||
Name extends keyof Spec,
|
||||
>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
options?: ModelHookOptions,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 type { JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
readonly sessionID: Session.ID
|
||||
@@ -15,6 +15,14 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionModelRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
baseURL?: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -32,6 +40,7 @@ export interface SessionHttpResponse {
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
@@ -40,5 +49,5 @@ export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -130,8 +130,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
reload: () => run(host.agent.reload()),
|
||||
},
|
||||
aisdk: {
|
||||
hook: (name, callback) =>
|
||||
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: (name, callback, options) =>
|
||||
register(
|
||||
host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
|
||||
),
|
||||
},
|
||||
catalog: {
|
||||
provider: {
|
||||
@@ -295,8 +297,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: (name, callback, options) =>
|
||||
register(
|
||||
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
|
||||
),
|
||||
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
|
||||
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
|
||||
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Hooks } from "./registration.js"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface AISDKHooks {
|
||||
sdk: {
|
||||
@@ -18,5 +18,5 @@ export interface AISDKHooks {
|
||||
}
|
||||
|
||||
export interface AISDKDomain {
|
||||
readonly hook: Hooks<AISDKHooks>
|
||||
readonly hook: ModelHooks<AISDKHooks>
|
||||
}
|
||||
|
||||
@@ -2,9 +2,20 @@ export interface Registration {
|
||||
readonly dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface ModelHookOptions {
|
||||
/** Limits the hook to one provider. Unscoped hooks apply to every provider. */
|
||||
readonly providerID?: string
|
||||
}
|
||||
|
||||
export type Hooks<Spec> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Promise<void> | void,
|
||||
) => Promise<Registration>
|
||||
|
||||
export type ModelHooks<Spec> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Promise<void> | void,
|
||||
options?: ModelHookOptions,
|
||||
) => Promise<Registration>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 type { JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
import type { ModelHooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
readonly sessionID: Session.ID
|
||||
@@ -15,6 +15,14 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionModelRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
baseURL?: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SessionHttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -32,6 +40,7 @@ export interface SessionHttpResponse {
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
}
|
||||
@@ -40,5 +49,5 @@ export type SessionDomain = Pick<
|
||||
SessionApi,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()(
|
||||
"InvalidRequestError",
|
||||
@@ -35,6 +36,17 @@ export class SessionBusyError extends Schema.TaggedError<SessionBusyError>()(
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class SeqUnavailableError extends Schema.TaggedError<SeqUnavailableError>()(
|
||||
"SeqUnavailableError",
|
||||
{
|
||||
sessionID: Schema.String,
|
||||
after: Event.Seq,
|
||||
head: Schema.optional(Event.Seq),
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class ServiceUnavailableError extends Schema.TaggedError<ServiceUnavailableError>()(
|
||||
"ServiceUnavailableError",
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
InvalidRequestError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SeqUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
@@ -219,6 +220,30 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.snapshot", "/api/session/:sessionID/snapshot", {
|
||||
params: { sessionID: Session.ID },
|
||||
query: {
|
||||
recent: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
|
||||
},
|
||||
success: Schema.Struct({
|
||||
data: Schema.Struct({
|
||||
session: Session.Info,
|
||||
children: Schema.Array(Session.Info),
|
||||
inbox: Schema.Array(SessionInbox.Info),
|
||||
messages: Schema.Array(SessionMessage.Info),
|
||||
seq: Event.Seq,
|
||||
}),
|
||||
}).annotate({ identifier: "SessionSnapshotResponse" }),
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.snapshot",
|
||||
summary: "Snapshot session state",
|
||||
description: "Retrieve projected session state and its aggregate sequence from one consistent read.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("session.remove", "/api/session/:sessionID", {
|
||||
params: { sessionID: Session.ID },
|
||||
@@ -633,17 +658,18 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
query: {
|
||||
after: Schema.NumberFromString.pipe(Schema.decodeTo(Event.Seq), Schema.optional),
|
||||
follow: BooleanFromString.pipe(Schema.optional),
|
||||
ephemeral: BooleanFromString.pipe(Schema.optional),
|
||||
},
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Schema.Union([SessionEvent.Durable, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
|
||||
data: Schema.Union([SessionEvent.All, EventLog.Synced]).annotate({ identifier: "SessionLogItem" }),
|
||||
}),
|
||||
error: SessionNotFoundError,
|
||||
error: [SessionNotFoundError, SeqUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.log",
|
||||
summary: "Read the session log",
|
||||
description:
|
||||
"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
|
||||
"Experimental session event log. Replay is durable-only; follow mode can opt into live ephemeral events.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
InvalidCursorError,
|
||||
MessageNotFoundError,
|
||||
ServiceUnavailableError,
|
||||
SeqUnavailableError,
|
||||
SessionBusyError,
|
||||
SessionNotFoundError,
|
||||
SkillNotFoundError,
|
||||
@@ -26,16 +27,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const sessionNotFound = (error: Session.NotFoundError) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
})
|
||||
const messageDecodeFailed = (error: Session.MessageDecodeError) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
|
||||
),
|
||||
)
|
||||
}
|
||||
const pendingMutation = (effect: ReturnType<typeof session.cancelInbox>, conflict: string) =>
|
||||
effect.pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.NotFoundError", sessionNotFound),
|
||||
Effect.catchTag(
|
||||
"Session.InboxConflictError",
|
||||
(error) => new ConflictError({ resource: error.inboxID, message: `${conflict}: ${error.inboxID}` }),
|
||||
@@ -131,25 +139,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.catchTag("Session.NotFoundError", sessionNotFound),
|
||||
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
|
||||
),
|
||||
}
|
||||
}),
|
||||
@@ -180,6 +171,19 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.snapshot",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session
|
||||
.snapshot({ sessionID: ctx.params.sessionID, recent: ctx.query.recent })
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", sessionNotFound),
|
||||
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
@@ -664,25 +668,8 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.context(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Effect.catchTag("Session.NotFoundError", sessionNotFound),
|
||||
Effect.catchTag("Session.MessageDecodeError", messageDecodeFailed),
|
||||
),
|
||||
}
|
||||
}),
|
||||
@@ -773,19 +760,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
.handle(
|
||||
"session.log",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* session.get(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return session
|
||||
.log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow })
|
||||
.pipe(Stream.orDie)
|
||||
return yield* session
|
||||
.openLog({
|
||||
sessionID: ctx.params.sessionID,
|
||||
after: ctx.query.after,
|
||||
follow: ctx.query.follow,
|
||||
ephemeral: ctx.query.ephemeral,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error._tag === "Session.NotFoundError"
|
||||
? sessionNotFound(error)
|
||||
: new SeqUnavailableError({
|
||||
sessionID: error.sessionID,
|
||||
after: error.after,
|
||||
head: error.head,
|
||||
message: `Session log is unavailable after sequence ${error.after}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
|
||||
@@ -208,6 +208,11 @@ export function Prompt(props: PromptProps) {
|
||||
const config = useConfig().data
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
onCleanup(
|
||||
data.session.failures.listen((failure) => {
|
||||
toast.show({ title: "Prompt rejected", message: failure.reason, variant: "error" })
|
||||
}),
|
||||
)
|
||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
@@ -1304,7 +1309,7 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const error = await client.api.session
|
||||
const error = await data.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: inputText,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createData } from "@opencode-ai/client/solid"
|
||||
import { createEngineData } from "@opencode-ai/client/solid"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
@@ -10,7 +10,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: () => {
|
||||
const client = useClient()
|
||||
const data = createData({
|
||||
const data = createEngineData({
|
||||
api: () => client.api,
|
||||
event: client.event,
|
||||
connection: client.connection,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useRoute, useRouteData } from "../../../context/route"
|
||||
import { useData } from "../../../context/data"
|
||||
import { useClient } from "../../../context/client"
|
||||
@@ -9,6 +10,7 @@ import { Locale } from "../../../util/locale"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./index"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionFamily } from "../../../util/session"
|
||||
|
||||
interface SubagentEntry {
|
||||
sessionID: string
|
||||
@@ -16,6 +18,7 @@ interface SubagentEntry {
|
||||
title: string
|
||||
status: string
|
||||
current: boolean
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export function SubagentsTab(props: { sessionID: string }) {
|
||||
@@ -34,47 +37,24 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const current = session()
|
||||
if (!current) return []
|
||||
|
||||
const result: SubagentEntry[] = []
|
||||
|
||||
if (current.parentID) {
|
||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||
for (const sibling of siblings) {
|
||||
const title = withTimestampedFallback(sibling)
|
||||
const result = sessionFamily<SessionInfo>(data.session.list(), current.id).map(
|
||||
({ session, prefix }): SubagentEntry => {
|
||||
const title = withTimestampedFallback(session)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = sibling.agent
|
||||
? Locale.titlecase(sibling.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
|
||||
result.push({
|
||||
sessionID: sibling.id,
|
||||
agent,
|
||||
title: name,
|
||||
status: data.session.status(sibling.id),
|
||||
current: sibling.id === route.sessionID,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||
for (const child of children) {
|
||||
const title = withTimestampedFallback(child)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const agent = child.agent
|
||||
? Locale.titlecase(child.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title
|
||||
result.push({
|
||||
sessionID: child.id,
|
||||
agent,
|
||||
title: name,
|
||||
status: data.session.status(child.id),
|
||||
current: child.id === route.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionID: session.id,
|
||||
agent: session.agent
|
||||
? Locale.titlecase(session.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent",
|
||||
title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title,
|
||||
status: data.session.status(session.id),
|
||||
current: session.id === route.sessionID,
|
||||
prefix,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
|
||||
})
|
||||
@@ -264,6 +244,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{entry.prefix}
|
||||
{entry.agent}: {entry.title}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -213,13 +213,25 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
const input = (event: SessionInboxEnqueued) => {
|
||||
if (
|
||||
event.data.sessionID === sessionID() &&
|
||||
event.data.item.delivery !== "queue" &&
|
||||
(event.data.item.type === "user" ||
|
||||
(event.data.item.type === "synthetic" && event.data.item.payload.description?.trim()))
|
||||
)
|
||||
appendMessage(event.data.inboxID)
|
||||
}
|
||||
const delivery = (event: { data: { sessionID: string; inboxID: string; delivery: "steer" | "queue" } }) => {
|
||||
if (event.data.sessionID !== sessionID()) return
|
||||
if (event.data.delivery === "steer") return appendMessage(event.data.inboxID)
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
const index = draft.findIndex((row) => row.type === "message" && row.messageID === event.data.inboxID)
|
||||
if (index !== -1) draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.inbox.enqueued", input),
|
||||
data.on("session.inbox.delivery.changed", delivery),
|
||||
data.on("session.compaction.started", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Locale } from "./locale"
|
||||
|
||||
type SessionNode = {
|
||||
id: string
|
||||
parentID?: string | null
|
||||
}
|
||||
|
||||
export function sessionFamily<T extends SessionNode>(sessions: readonly T[], sessionID: string) {
|
||||
const byID = new Map(sessions.map((session) => [session.id, session]))
|
||||
const current = byID.get(sessionID)
|
||||
if (!current) return []
|
||||
|
||||
const children = new Map<string, T[]>()
|
||||
sessions.forEach((session) => {
|
||||
if (!session.parentID) return
|
||||
const group = children.get(session.parentID)
|
||||
if (group) group.push(session)
|
||||
else children.set(session.parentID, [session])
|
||||
})
|
||||
|
||||
function root(session: T): T {
|
||||
const parent = session.parentID ? byID.get(session.parentID) : undefined
|
||||
return parent ? root(parent) : session
|
||||
}
|
||||
|
||||
function walk(parentID: string, ancestors: boolean[]): Array<{ session: T; prefix: string }> {
|
||||
const group = children.get(parentID) ?? []
|
||||
return group.flatMap((session, index) => {
|
||||
const last = index === group.length - 1
|
||||
const prefix =
|
||||
ancestors.length === 0
|
||||
? ""
|
||||
: ancestors
|
||||
.slice(1)
|
||||
.map((ancestor) => (ancestor ? " " : "│ "))
|
||||
.join("") + (last ? "└─ " : "├─ ")
|
||||
return [{ session, prefix }, ...walk(session.id, [...ancestors, last])]
|
||||
})
|
||||
}
|
||||
|
||||
return walk(root(current).id, [])
|
||||
}
|
||||
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
|
||||
@@ -2592,18 +2592,7 @@ test("reconciles active session forms when the event stream reconnects", async (
|
||||
|
||||
test("settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message/msg_model_1")
|
||||
return json({
|
||||
data: {
|
||||
id: "msg_model_1",
|
||||
type: "model-switched",
|
||||
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
|
||||
model: { id: "model-1", providerID: "provider-1", variant: "high" },
|
||||
time: { created: 0 },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
const calls = createFetch(undefined, events)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
@@ -2630,6 +2619,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await sync.session.message.sync("session-1")
|
||||
emitEvent(events, {
|
||||
id: "evt_agent_1",
|
||||
created: 0,
|
||||
@@ -2645,6 +2635,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
model: { id: "model-1", providerID: "provider-1", variant: "high" },
|
||||
previous: { id: "model-1", providerID: "provider-1", variant: "medium" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
|
||||
@@ -27,7 +27,10 @@ test("scopes sessions to the active session location", async () => {
|
||||
const active = "/tmp/opencode/project-b"
|
||||
const events = createEventStream()
|
||||
const requestedProjects: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
const calls = createFetch((url, request) => {
|
||||
// The fixture's snapshot synthesizer reads through this handler; only app
|
||||
// traffic belongs in the scoping assertions.
|
||||
if (request.headers.get("x-fixture-synthetic")) return undefined
|
||||
if (url.pathname === "/api/location") {
|
||||
const directory = url.searchParams.get("location[directory]") ?? process.cwd()
|
||||
const project = directory === active ? "proj_b" : "proj_a"
|
||||
|
||||
@@ -16,6 +16,9 @@ export function createEventStream() {
|
||||
const encoder = new TextEncoder()
|
||||
const v2 = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const pending: Uint8Array[] = []
|
||||
const logs = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>()
|
||||
const logSeq = new Map<string, number>()
|
||||
const logHistory = new Map<string, Array<{ readonly seq: number; readonly event: unknown }>>()
|
||||
const response = (
|
||||
controllers: Set<ReadableStreamDefaultController<Uint8Array>>,
|
||||
queued: Uint8Array[],
|
||||
@@ -27,7 +30,8 @@ export function createEventStream() {
|
||||
start(controller) {
|
||||
current = controller
|
||||
controllers.add(controller)
|
||||
if (initial) controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`))
|
||||
const values = Array.isArray(initial) ? initial : initial ? [initial] : []
|
||||
for (const value of values) controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
|
||||
for (const chunk of queued.splice(0)) controller.enqueue(chunk)
|
||||
},
|
||||
cancel() {
|
||||
@@ -53,13 +57,47 @@ export function createEventStream() {
|
||||
return {
|
||||
emit(event: OpenCodeEvent) {
|
||||
send(v2, pending, event)
|
||||
const sessionID =
|
||||
"durable" in event
|
||||
? event.durable.aggregateID
|
||||
: "sessionID" in event.data && typeof event.data.sessionID === "string"
|
||||
? event.data.sessionID
|
||||
: undefined
|
||||
if (!sessionID) return
|
||||
const seq = (logSeq.get(sessionID) ?? 0) + 1
|
||||
const item = "durable" in event ? { ...event, durable: { ...event.durable, seq } } : event
|
||||
if ("durable" in event) {
|
||||
logSeq.set(sessionID, seq)
|
||||
logHistory.set(sessionID, [...(logHistory.get(sessionID) ?? []), { seq, event: item }])
|
||||
}
|
||||
const controllers = logs.get(sessionID)
|
||||
if (controllers) send(controllers, [], item)
|
||||
},
|
||||
v2() {
|
||||
return response(v2, pending, { id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
log(sessionID: string, after: number) {
|
||||
const controllers = logs.get(sessionID) ?? new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
logs.set(sessionID, controllers)
|
||||
return response(
|
||||
controllers,
|
||||
[],
|
||||
[
|
||||
...(logHistory.get(sessionID) ?? []).filter((entry) => entry.seq > after).map((entry) => entry.event),
|
||||
{ type: "log.synced", aggregateID: sessionID, seq: logSeq.get(sessionID) ?? 0 },
|
||||
],
|
||||
)
|
||||
},
|
||||
seq(sessionID: string) {
|
||||
return logSeq.get(sessionID) ?? 0
|
||||
},
|
||||
disconnect() {
|
||||
for (const controller of v2) controller.close()
|
||||
v2.clear()
|
||||
for (const controllers of logs.values()) {
|
||||
for (const controller of controllers) controller.close()
|
||||
controllers.clear()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -68,6 +106,8 @@ export type FetchHandler = (url: URL, request: Request) => Response | undefined
|
||||
|
||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
||||
const session = [] as URL[]
|
||||
const sessionEvents = events ?? createEventStream()
|
||||
const snapshots = new Map<string, number>()
|
||||
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
@@ -75,6 +115,47 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
const overridden = await override?.(url, request)
|
||||
if (overridden) return overridden
|
||||
if (url.pathname === "/api/event" && events) return events.v2()
|
||||
const snapshot = url.pathname.match(/^\/api\/session\/([^/]+)\/snapshot$/)
|
||||
if (snapshot) {
|
||||
const sessionID = decodeURIComponent(snapshot[1])
|
||||
const count = snapshots.get(sessionID) ?? 0
|
||||
snapshots.set(sessionID, count + 1)
|
||||
// Synthetic sub-reads reuse the test override as the data source but are
|
||||
// not app traffic; the marker header lets request-observing tests skip them.
|
||||
const read = async (path: string, fallback: unknown) => {
|
||||
const target = new URL(path, url)
|
||||
const response = await override?.(target, new Request(target, { headers: { "x-fixture-synthetic": "snapshot" } }))
|
||||
if (!response) return fallback
|
||||
const body = await response.json()
|
||||
if (typeof body !== "object" || body === null || !("data" in body)) return fallback
|
||||
return body.data
|
||||
}
|
||||
const children = await read(`/api/session?parentID=${encodeURIComponent(sessionID)}`, [])
|
||||
const messages = await read(`/api/session/${encodeURIComponent(sessionID)}/message`, [])
|
||||
return json({
|
||||
data: {
|
||||
session: await read(`/api/session/${encodeURIComponent(sessionID)}`, {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}),
|
||||
children: Array.isArray(children)
|
||||
? children.filter(
|
||||
(child) =>
|
||||
typeof child === "object" && child !== null && "parentID" in child && child.parentID === sessionID,
|
||||
)
|
||||
: [],
|
||||
inbox: await read(`/api/session/${encodeURIComponent(sessionID)}/inbox`, []),
|
||||
messages: Array.isArray(messages) ? messages.toReversed() : [],
|
||||
seq: count === 0 ? 0 : sessionEvents.seq(sessionID),
|
||||
},
|
||||
})
|
||||
}
|
||||
const log = url.pathname.match(/^\/api\/experimental\/session\/([^/]+)\/log$/)
|
||||
if (log) return sessionEvents.log(decodeURIComponent(log[1]), Number(url.searchParams.get("after") ?? 0))
|
||||
|
||||
if (
|
||||
[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { lastAssistantWithUsage } from "../../src/util/session"
|
||||
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
@@ -13,6 +13,27 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
})
|
||||
|
||||
describe("util.session", () => {
|
||||
test("flattens nested subagents from any session in the family", () => {
|
||||
const sessions = [
|
||||
{ id: "root" },
|
||||
{ id: "child-a", parentID: "root" },
|
||||
{ id: "grandchild-a", parentID: "child-a" },
|
||||
{ id: "great-grandchild-a", parentID: "grandchild-a" },
|
||||
{ id: "grandchild-a2", parentID: "child-a" },
|
||||
{ id: "child-b", parentID: "root" },
|
||||
{ id: "grandchild-b", parentID: "child-b" },
|
||||
]
|
||||
|
||||
expect(sessionFamily(sessions, "great-grandchild-a")).toEqual([
|
||||
{ session: sessions[1], prefix: "" },
|
||||
{ session: sessions[2], prefix: "├─ " },
|
||||
{ session: sessions[3], prefix: "│ └─ " },
|
||||
{ session: sessions[4], prefix: "└─ " },
|
||||
{ session: sessions[5], prefix: "" },
|
||||
{ session: sessions[6], prefix: "└─ " },
|
||||
])
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user