From eee2eb1e7324bbc39c94c82b786d401fdf8bb5f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 24 Jun 2026 23:48:28 -0400 Subject: [PATCH 1/9] feat(sdk): restore session runtime operations --- AGENTS.md | 4 +- CONTEXT.md | 10 +- .../client/src/generated-effect/client.ts | 34 +- packages/client/src/generated/client.ts | 40 + packages/client/src/generated/types.ts | 764 ++++++++++++++++++ packages/client/test/effect.test.ts | 48 +- packages/client/test/promise.test.ts | 34 + packages/core/src/event.ts | 49 ++ packages/core/src/session.ts | 21 +- packages/core/test/event.test.ts | 137 ++++ packages/core/test/session-prompt.test.ts | 16 +- .../test/session-runner-tool-events.test.ts | 1 + packages/httpapi-codegen/README.md | 4 +- packages/httpapi-codegen/src/index.ts | 47 +- .../httpapi-codegen/test/generate.test.ts | 8 +- packages/protocol/src/groups/session.ts | 56 +- packages/sdk-next/README.md | 4 +- packages/sdk-next/src/opencode.ts | 46 +- packages/sdk-next/test/embedded.test.ts | 38 +- packages/server/src/handlers/session.ts | 29 +- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 8 +- 22 files changed, 1315 insertions(+), 85 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fe10ba44d82..cd2327e88811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ -- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. +- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. +- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. diff --git a/CONTEXT.md b/CONTEXT.md index 97919b63f66a..24f79caa4449 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,7 +61,7 @@ A temporary file created under OpenCode's shared tool-output directory to retain The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. **OpenCode Client**: -The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers. +The generated Promise and Effect APIs derived from the public `HttpApi`; **Embedded OpenCode** shares the Effect API through an in-memory `HttpClient` against the same router and handlers. _Avoid_: Remote client **SDK Contract IR**: @@ -149,17 +149,19 @@ _Avoid_: Response envelope - SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. - The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. - A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. -- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. +- `sessions.events({ sessionID, after })` is a public raw Session event stream transported as SSE in both networked and embedded modes. It verifies the Session, replays durable events after the optional aggregate sequence, then continues with raw live events for that Session, including durable events and ephemeral text, reasoning, and tool-input fragments. +- A `sessions.events(...)` recovery cursor is the greatest observed `event.durable.seq`. Durable events carry their aggregate sequence in the existing raw event envelope; ephemeral events omit `durable` and never advance the cursor. Reconnecting with `after` replays only later durable events because ephemeral events are not recoverable. +- The replay-to-live handoff subscribes to live events before reading durable history, emits historical durable events first, buffers concurrent live events during catch-up, deduplicates buffered durable events already covered by replay, then flushes the remaining buffer in publication order before continuing live. - `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. - A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. - The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. - `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. -- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold mixed event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; ephemeral events observed after that sequence are intentionally not replayed. Any reusable resume helper remains a separate API design question. - The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. - Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. - A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. - `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid. -- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. +- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary. - `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op. - `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate. - **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata? diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 9088c533fafb..5d4a58464b7b 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -1,5 +1,5 @@ // Generated by @opencode-ai/httpapi-codegen. Do not edit. -import { Effect, Schema } from "effect" +import { Effect, Stream, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" @@ -143,6 +143,35 @@ const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11I Effect.map((value) => value.data), ) +type Endpoint0_12Request = Parameters[0] +type Endpoint0_12Input = { + readonly sessionID: Endpoint0_12Request["params"]["sessionID"] + readonly after?: Endpoint0_12Request["query"]["after"] +} +const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => + Stream.unwrap( + raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +type Endpoint0_13Request = Parameters[0] +type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] } +const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => + raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_14Request = Parameters[0] +type Endpoint0_14Input = { + readonly sessionID: Endpoint0_14Request["params"]["sessionID"] + readonly messageID: Endpoint0_14Request["params"]["messageID"] +} +const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => + raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + const adaptGroup0 = (raw: RawClient["server.session"]) => ({ list: Endpoint0_0(raw), create: Endpoint0_1(raw), @@ -156,6 +185,9 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ clear: Endpoint0_9(raw), commit: Endpoint0_10(raw), context: Endpoint0_11(raw), + events: Endpoint0_12(raw), + interrupt: Endpoint0_13(raw), + message: Endpoint0_14(raw), }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 781ba9730c7b..b1aa2a24059f 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -23,6 +23,12 @@ import type { SessionsCommitOutput, SessionsContextInput, SessionsContextOutput, + SessionsEventsInput, + SessionsEventsOutput, + SessionsInterruptInput, + SessionsInterruptOutput, + SessionsMessageInput, + SessionsMessageOutput, } from "./types" import { ClientError } from "./client-error" @@ -306,6 +312,40 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => + sse( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, + query: { after: input.after }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), + interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + message: (input: SessionsMessageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsMessageOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index f51e74279872..20a577951267 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -592,3 +592,767 @@ export type SessionsContextOutput = { } > }["data"] + +export type SessionsEventsInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly after?: { readonly after?: string | undefined }["after"] +} + +export type SessionsEventsOutput = + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + readonly subdirectory?: string | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: + | ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | undefined + readonly description?: string | undefined + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + readonly agents?: + | ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined + }> + | undefined + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + readonly snapshot?: string | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string | undefined + readonly files?: ReadonlyArray | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.text.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.reasoning.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.input.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: unknown } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: any } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | undefined } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: any } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | undefined } + > + readonly outputPaths?: ReadonlyArray | undefined + readonly result?: unknown | undefined + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: unknown | undefined + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number | undefined + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } | undefined + readonly responseBody?: string | undefined + readonly metadata?: { readonly [x: string]: string } | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.compaction.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string | undefined + readonly snapshot?: string | undefined + readonly diff?: string | undefined + readonly files?: + | ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + +export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionsInterruptOutput = void + +export type SessionsMessageInput = { + readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] + readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] +} + +export type SessionsMessageOutput = { + readonly data: + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "agent-switched" + readonly agent: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "model-switched" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly type: "user" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly sessionID: string + readonly text: string + readonly type: "synthetic" + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + readonly type: "system" + readonly text: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "shell" + readonly callID: string + readonly command: string + readonly output: string + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number; readonly completed?: number | null } + readonly type: "assistant" + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly id: string; readonly text: string } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } + | { + readonly type: "tool" + readonly id: string + readonly name: string + readonly provider?: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + } | null + readonly state: + | { readonly status: "pending"; readonly input: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: JsonValue } + readonly structured: { readonly [x: string]: any } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: JsonValue } + readonly attachments?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string | null + readonly description?: string | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null + }> | null + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly outputPaths?: ReadonlyArray | null + readonly structured: { readonly [x: string]: any } + readonly result?: JsonValue | null + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { + readonly type: "file" + readonly uri: string + readonly mime: string + readonly name?: string | null + } + > + readonly structured: { readonly [x: string]: any } + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue | null + } + readonly time: { + readonly created: number + readonly ran?: number | null + readonly completed?: number | null + readonly pruned?: number | null + } + } + > + readonly snapshot?: { + readonly start?: string | null + readonly end?: string | null + readonly files?: ReadonlyArray | null + } | null + readonly finish?: string | null + readonly cost?: number | null + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } | null + readonly error?: { readonly type: "unknown"; readonly message: string } | null + } + | { + readonly type: "compaction" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly time: { readonly created: number } + } +}["data"] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 5613c3ce7765..f9fbf1b96f22 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -18,12 +18,25 @@ test("sessions.get returns the decoded Effect projection", async () => { test("session methods retain decoded Effect inputs and outputs", async () => { const httpClient = HttpClient.make((request) => { const url = request.url + if (url.includes("/event")) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + } if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } if (url.includes("/context")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) } + if (url.includes("/message/")) { + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: modelSwitchedMessage }))) + } if (request.method === "POST" && url.endsWith("/api/session")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))) } @@ -53,7 +66,15 @@ test("session methods retain decoded Effect inputs and outputs", async () => { yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) - return { page, created, admitted, context } + const events = yield* client.sessions + .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) + .pipe(Stream.runCollect) + yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") }) + const message = yield* client.sessions.message({ + sessionID: Session.ID.make("ses_test"), + messageID: SessionMessage.ID.make("msg_model"), + }) + return { page, created, admitted, context, events, message } }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) @@ -64,6 +85,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) const session = { @@ -96,3 +119,22 @@ const admission = { timeCreated: 1_717_171_717_000, }, } + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a7fac9780fa8..16a475b443c6 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -24,8 +24,14 @@ test("session methods use the public HTTP contract", async () => { fetch: async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url requests.push({ url, init }) + if (url.includes("/event")) { + return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + } if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/context")) return Response.json({ data: [] }) + if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST") return new Response(null, { status: 204 }) return Response.json({ data: [session.data], cursor: { next: "next" } }) @@ -47,11 +53,17 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) + const events = [] + for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) + await client.sessions.interrupt({ sessionID: "ses_test" }) + const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) expect(page.cursor.next).toBe("next") expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) + expect(events).toEqual([modelSwitchedEvent]) + expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ ["GET", "http://localhost:3000/api/session?limit=10&order=desc"], ["POST", "http://localhost:3000/api/session"], @@ -61,6 +73,9 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], + ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], + ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ]) const body = requests[4]?.init?.body if (typeof body !== "string") throw new Error("Expected JSON request body") @@ -115,3 +130,22 @@ const admission = { timeCreated: 1_717_171_717_000, }, } + +const modelSwitchedMessage = { + id: "msg_model", + type: "model-switched", + time: { created: 1_717_171_717_000 }, + model: { id: "claude", providerID: "anthropic" }, +} + +const modelSwitchedEvent = { + id: "evt_model", + type: "session.next.model.switched", + durable: { aggregateID: "ses_test", seq: 1, version: 1 }, + data: { + timestamp: 1_717_171_717_000, + sessionID: "ses_test", + messageID: "msg_model", + model: { id: "claude", providerID: "anthropic" }, + }, +} diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 132a88b11125..052e7f83460a 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -67,6 +67,11 @@ export interface Interface { readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream + readonly follow: (input: { + readonly aggregateID: string + readonly after?: number + readonly matches: (event: Payload) => event is A + }) => Stream.Stream /** @deprecated Use `all()` and consume the returned stream. */ readonly listen: (listener: Subscriber) => Effect.Effect readonly project: (definition: D, projector: Subscriber) => Effect.Effect @@ -86,6 +91,7 @@ export class Service extends Context.Service()("@opencode/Ev export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect + readonly followerCapacity?: number } export const layerWith = (options?: LayerOptions) => @@ -546,6 +552,48 @@ export const layerWith = (options?: LayerOptions) => }) }) + const follow = (input: { + readonly aggregateID: string + readonly after?: number + readonly matches: (event: Payload) => event is A + }): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const pubsub = yield* PubSub.dropping(options?.followerCapacity ?? 1024) + const subscription = yield* PubSub.subscribe(pubsub) + const listener = (event: Payload) => + input.matches(event) + ? PubSub.publish(pubsub, event).pipe( + Effect.flatMap((published) => (published ? Effect.void : PubSub.shutdown(pubsub))), + ) + : Effect.void + yield* Effect.acquireRelease(listen(listener), (unsubscribe) => + unsubscribe.pipe(Effect.andThen(PubSub.shutdown(pubsub))), + ) + let sequence = input.after ?? -1 + const read: Effect.Effect> = Effect.suspend(() => + readAfter(input.aggregateID, sequence), + ).pipe( + Effect.map((events) => events.flatMap((event) => (input.matches(event) ? [event] : []))), + Effect.tap((events) => + Effect.sync(() => { + sequence = events.at(-1)?.durable?.seq ?? sequence + }), + ), + ) + const historical = yield* read + const live = Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => { + if (event.durable?.aggregateID !== input.aggregateID) return true + if (event.durable.seq <= sequence) return false + sequence = event.durable.seq + return true + }), + ) + return Stream.fromIterable(historical).pipe(Stream.concat(live)) + }), + ) + const project = (definition: D, projector: Subscriber): Effect.Effect => Effect.sync(() => { const list = projectors.get(definition.type) ?? [] @@ -558,6 +606,7 @@ export const layerWith = (options?: LayerOptions) => subscribe, all: streamAll, durable, + follow, listen, project, replay, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 59c66b760a58..0f512acee05e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -128,7 +128,7 @@ export interface Interface { readonly events: (input: { sessionID: SessionSchema.ID after?: number - }) => Stream.Stream + }) => Stream.Stream readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID @@ -184,7 +184,10 @@ export const layer = Layer.unwrap( const store = yield* SessionStore.Service const locations = yield* LocationServiceMap const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isDurableSessionEvent = Schema.is(SessionEvent.Durable) + const isSessionEvent = + (sessionID: SessionSchema.ID) => + (event: EventV2.Payload): event is SessionEvent.Event => + Schema.is(SessionEvent.All)(event) && event.data.sessionID === sessionID const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -340,10 +343,16 @@ export const layer = Layer.unwrap( }), events: (input) => Stream.unwrap( - result - .get(input.sessionID) - .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), - ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + result.get(input.sessionID).pipe( + Effect.as( + events.follow({ + aggregateID: input.sessionID, + after: input.after, + matches: isSessionEvent(input.sessionID), + }), + ), + ), + ), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index e2b2a5df046c..12c9bf2db2bc 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -58,6 +58,14 @@ const GlobalMessage = EventV2.define({ }, }) +const LiveMessage = EventV2.define({ + type: "test.live", + schema: { + sessionID: Session.ID, + text: Schema.String, + }, +}) + const VersionedMessage = EventV2.define({ type: "test.versioned", durable: { @@ -418,6 +426,135 @@ describe("EventV2", () => { }), ) + it.effect("replays durable history before buffered matching live events", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make() + const continueRead = yield* Deferred.make() + let pause = true + const eventLayer = EventV2.layerWith({ + beforeAggregateRead: () => + pause + ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) + : Effect.void, + }).pipe(Layer.provide(Database.defaultLayer)) + + yield* Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + yield* events.publish(DurableMessage, durableData(aggregateID, "before")) + const matches = ( + event: EventV2.Payload, + ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => + (event.type === DurableMessage.type || event.type === LiveMessage.type) && + typeof event.data === "object" && + event.data !== null && + "sessionID" in event.data && + event.data.sessionID === aggregateID + const fiber = yield* events + .follow({ aggregateID, matches }) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + yield* Deferred.await(readStarted) + + pause = false + yield* events.publish(DurableMessage, durableData(aggregateID, "during")) + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "live" }) + yield* Deferred.succeed(continueRead, undefined) + + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.type])).toEqual([ + [0, "message.removed"], + [1, "message.removed"], + [undefined, "test.live"], + ]) + }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) + }), + ) + + it.effect("preserves raw live publication order around durable events", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + const matches = ( + event: EventV2.Payload, + ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => + (event.type === DurableMessage.type || event.type === LiveMessage.type) && + typeof event.data === "object" && + event.data !== null && + "sessionID" in event.data && + event.data.sessionID === aggregateID + const fiber = yield* events + .follow({ aggregateID, matches }) + .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + + yield* events.publish(DurableMessage, durableData(aggregateID, "first")) + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "between" }) + yield* events.publish(DurableMessage, durableData(aggregateID, "second")) + + expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.type])).toEqual([ + [0, "message.removed"], + [undefined, "test.live"], + [1, "message.removed"], + ]) + }), + ) + + it.effect("terminates a lagging follower without blocking publishers", () => + Effect.gen(function* () { + const readStarted = yield* Deferred.make() + const continueRead = yield* Deferred.make() + const eventLayer = EventV2.layerWith({ + followerCapacity: 1, + beforeAggregateRead: () => + Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))), + }).pipe(Layer.provide(Database.defaultLayer)) + + yield* Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + const matches = ( + event: EventV2.Payload, + ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => + event.type === LiveMessage.type && + typeof event.data === "object" && + event.data !== null && + "sessionID" in event.data && + event.data.sessionID === aggregateID + const fiber = yield* events.follow({ aggregateID, matches }).pipe(Stream.runCollect, Effect.forkScoped) + yield* Deferred.await(readStarted) + + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "fills buffer" }) + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "closes follower" }) + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "publisher remains live" }) + yield* Deferred.succeed(continueRead, undefined) + + expect(Array.from(yield* Fiber.join(fiber)).length).toBeLessThanOrEqual(1) + }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) + }), + ) + + it.effect("removes a follower listener when its stream is interrupted", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = Session.ID.create() + let matches = 0 + const fiber = yield* events + .follow({ + aggregateID, + matches: (_event): _event is EventV2.Payload => { + matches++ + return false + }, + }) + .pipe(Stream.runDrain, Effect.forkScoped) + yield* Effect.yieldNow + yield* Fiber.interrupt(fiber) + + yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "after interruption" }) + + expect(matches).toBe(0) + }), + ) + it.effect("coalesces durable aggregate wakes while draining every committed event", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index af30c4d87783..b600673e8ae8 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -164,18 +164,25 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("streams durable Session events after an aggregate sequence", () => + it.effect("replays durable Session events then streams raw live events", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) + const fiber = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) + yield* events.publish(SessionEvent.Text.Delta, { + sessionID, + assistantMessageID: SessionMessage.ID.create(), + textID: "text_1", + timestamp: yield* DateTime.now, + delta: "live", + }) const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ @@ -183,12 +190,11 @@ describe("SessionV2.prompt", () => { [1, "session.next.prompt.admitted"], [2, "session.next.prompted"], [3, "session.next.prompted"], + [undefined, "session.next.text.delta"], ]) expect( Array.from( - yield* session - .events({ sessionID, after: streamed[0]!.durable?.seq }) - .pipe(Stream.take(1), Stream.runCollect), + yield* session.events({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), ).map((event) => [event.durable?.seq, event.type]), ).toEqual([[1, "session.next.prompt.admitted"]]) }), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index f96ea4dea2b0..9c4b182a0c91 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -29,6 +29,7 @@ const capture = () => { subscribe: () => Stream.empty, all: () => Stream.empty, durable: () => Stream.empty, + follow: () => Stream.empty, listen: () => Effect.succeed(Effect.void), project: () => Effect.void, replay: () => Effect.void, diff --git a/packages/httpapi-codegen/README.md b/packages/httpapi-codegen/README.md index 14a4dd3932af..0d31b129b72a 100644 --- a/packages/httpapi-codegen/README.md +++ b/packages/httpapi-codegen/README.md @@ -37,6 +37,6 @@ The existing public `generate(Api, { directory })` operation writes the rich Eff Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links. -Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. +Portable Effect output uses one self-contained module per `HttpApiGroup`, plus root client and index modules. Promise output uses shared type and client modules, while imported Effect output keeps adapters in the root client module. Schema dependencies may be duplicated across portable Effect group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it. -Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping. +Codegen preserves transport identifiers internally. `compile` may explicitly map consumer-facing group names, and endpoint operation IDs are projected to their final dot-delimited segment. The generator performs no other implicit product-specific naming or public-name annotation mapping. diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index d576b24cd19c..289884d961a9 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -69,6 +69,7 @@ type Slot = { const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") +const resolveContentSchema = SchemaAST.resolveAt("contentSchema") const Manifest = Schema.fromJsonString(Schema.Array(Schema.String)) const manifestName = ".httpapi-codegen.json" @@ -125,9 +126,10 @@ export function compile( ...responseSchemas(success.schema, `${name}.success`), ...errorSchemas.map((item) => [`${name}.error`, item.schema] as const), ] - const effectPortable = [params, query, headers, ...payloads, success, ...errorSchemas].every( - (item) => item?.effectPortable !== false, - ) + const effectPortable = + [params, query, headers, ...payloads, success, ...errorSchemas].every( + (item) => item?.effectPortable !== false, + ) && streamEffectPortable(success.schema) if (effectPortable) { for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } @@ -454,7 +456,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { const success = typeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" ? successSchema.sseMode === "data" - ? streamDataSchema(successSchema) + ? streamEncodedDataSchema(successSchema) : successSchema.events : successSchema, ) @@ -782,17 +784,6 @@ function responseSchemas(schema: Schema.Top, path: string): Array) { - const ast = Schema.toType(schema.events).ast + return Schema.make(streamDataAst(Schema.toType(schema.events).ast)) +} + +function streamEncodedDataSchema(schema: Extract) { + const data = streamDataAst(schema.events.ast) + const encodedAst = data.encoding?.at(-1)?.to + if (encodedAst === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) + const encoded = resolveContentSchema(encodedAst) + if (!SchemaAST.isAST(encoded)) throw new GenerationError({ reason: "Invalid SSE data schema" }) + return Schema.make(encoded) +} + +function streamDataAst(ast: SchemaAST.AST) { if (!SchemaAST.isObjects(ast)) throw new GenerationError({ reason: "Invalid SSE data schema" }) const data = ast.propertySignatures.find((field) => field.name === "data")?.type if (data === undefined) throw new GenerationError({ reason: "Invalid SSE data schema" }) - return Schema.make(data) + return data +} + +function streamEffectPortable(schema: Schema.Top) { + if (!isStreamSchema(schema) || schema._tag === "StreamUint8Array" || schema.sseMode === "events") return true + const rebuilt = HttpApiSchema.StreamSse({ + data: streamDataSchema(schema), + error: schema.error, + contentType: schema.contentType, + }) + return sameEncoding(schema.events.ast, rebuilt.events.ast) } function renderGroup(group: Group, groupIndex: number) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 8254652ec551..543b2a13f378 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -395,7 +395,9 @@ describe("HttpApiCodegen.generate", () => { api( HttpApiEndpoint.get("subscribe", "/event", { query: { after: Schema.optional(Schema.Number) }, - success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }), + success: HttpApiSchema.StreamSse({ + data: Schema.Struct({ type: Schema.String, count: Schema.NumberFromString }), + }), }), ), ), @@ -416,7 +418,7 @@ describe("HttpApiCodegen.generate", () => { return new Response( new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"type":"ready"}\r')) + controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r')) controller.enqueue(encoder.encode("\n\r\n")) controller.close() }, @@ -430,7 +432,7 @@ describe("HttpApiCodegen.generate", () => { expect(requests).toBe(0) const received = [] for await (const event of events) received.push(event) - expect(received).toEqual([{ type: "ready" }]) + expect(received).toEqual([{ type: "ready", count: "1" }]) expect(requests).toBe(1) expect(url).toBe("https://example.com/event?after=2") } finally { diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 431deb860eb3..610e85b4fecc 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input" import { Prompt } from "@opencode-ai/schema/prompt" import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" -import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" import { Context, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -20,6 +20,7 @@ import { Agent } from "@opencode-ai/schema/agent" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" import { Revert } from "@opencode-ai/schema/revert" +import { SessionEvent } from "@opencode-ai/schema/session-event" const SessionsQueryFields = { workspace: Workspace.ID.pipe(Schema.optional), @@ -32,6 +33,10 @@ const SessionsQueryFields = { search: Schema.optional(Schema.String), } +const SessionEventSchema: Schema.Codec = Schema.make( + SessionEvent.All.ast, +) + const SessionsDirectoryQuery = Schema.Struct({ ...SessionsQueryFields, directory: AbsolutePath, @@ -272,6 +277,55 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { + params: { sessionID: Session.ID }, + query: { + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), + }, + success: HttpApiSchema.StreamSse({ data: SessionEventSchema }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.events", + summary: "Subscribe to session events", + description: + "Replay durable events after an aggregate sequence, then continue with raw live Session events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.interrupt", "/api/session/:sessionID/interrupt", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.interrupt", + summary: "Interrupt session execution", + description: "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op.", + }), + ), + ) + .add( + HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { + params: { sessionID: Session.ID, messageID: SessionMessage.ID }, + success: Schema.Struct({ data: SessionMessage.Message }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.message", + summary: "Get session message", + description: "Retrieve one projected message owned by the Session.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "sessions", diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index 449bd8f6daca..8a292471b63d 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -11,7 +11,9 @@ const opencode = yield * OpenCode.create() const session = yield * opencode.sessions.get({ sessionID }) ``` -It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. +It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +`sessions.events({ sessionID, after })` first replays durable events after the optional aggregate sequence, then emits raw live Session events, including ephemeral deltas. Only `event.durable.seq` advances the recovery cursor. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. The same constructor is available as a service Layer: diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index ad89ae49aa04..ce282e18b8d0 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -2,40 +2,24 @@ import { OpenCode } from "@opencode-ai/client/effect" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Cause, Context, Effect, Layer } from "effect" -import { - HttpClient, - HttpRouter, - HttpServer, - HttpServerError, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http" +import { Context, Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { const applicationTools = ApplicationTools.layer - const { handler, permissions, tools } = yield* Effect.all({ - // Reusing this Layer value lets registration and every Location share one memoized host-level registry. - handler: HttpRouter.toHttpEffect( - createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)), - ), - permissions: PermissionSaved.Service, - tools: ApplicationTools.Service, - }).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer))) - const httpClient = HttpClient.make( - Effect.fnUntraced(function* (request) { - const response = yield* handler.pipe( - Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), - Effect.provideService(ApplicationTools.Service, tools), - Effect.provideService(PermissionSaved.Service, permissions), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt - : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)), - ), - ) - return HttpServerResponse.toClientResponse(response, { request }) - }, Effect.scoped), + const services = Layer.merge(applicationTools, PermissionSaved.defaultLayer) + const context = yield* Layer.build(services) + const web = HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe(Layer.provide(Layer.succeedContext(context)), Layer.provide(HttpServer.layerServices)), + { disableLogger: true }, + ) + yield* Effect.addFinalizer(() => Effect.promise(web.dispose)) + const tools = Context.get(context, ApplicationTools.Service) + const httpClient = HttpClient.make((request, _url, signal) => + Effect.gen(function* () { + const input = yield* HttpClientRequest.toWeb(request, { signal }).pipe(Effect.orDie) + return HttpClientResponse.fromWeb(request, yield* Effect.promise(() => web.handler(input, context))) + }), ) const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index e163d12c375b..df1b72dbfcf6 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" -import { Effect, Schema } from "effect" +import { Effect, Option, Schema, Stream } from "effect" test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) @@ -39,8 +39,31 @@ test("embedded client uses the real router and handlers", async () => { resume: false, }) const context = yield* opencode.sessions.context({ sessionID }) - const missing = yield* Effect.flip( - opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }), + const event = yield* opencode.sessions + .events({ sessionID }) + .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) + const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( + Option.getOrThrow, + ) + const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.sessions.interrupt({ sessionID }) + const other = yield* opencode.sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) + const missing = yield* Effect.all( + [ + opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ) + const missingMessage = yield* Effect.flip( + opencode.sessions.message({ + sessionID: other.id, + messageID: modelMessage.id, + }), ) expect(created.id).toBe(sessionID) @@ -49,7 +72,14 @@ test("embedded client uses the real router and handlers", async () => { expect(page.data.some((session) => session.id === sessionID)).toBe(true) expect(admitted.sessionID).toBe(sessionID) expect(context.some((message) => message.type === "model-switched")).toBe(true) - expect(missing._tag).toBe("SessionNotFoundError") + expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) + expect(message).toEqual(modelMessage) + expect(missing.map((error) => error._tag)).toEqual([ + "SessionNotFoundError", + "SessionNotFoundError", + "SessionNotFoundError", + ]) + expect(missingMessage._tag).toBe("MessageNotFoundError") }) await Effect.runPromise(Effect.scoped(program)) } finally { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 2ac6b1774261..e3b1270b94b4 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,5 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect } from "effect" +import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" @@ -318,5 +318,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.events", + Effect.fn((ctx) => + Effect.succeed( + session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie), + ), + ), + ) + .handle( + "session.interrupt", + Effect.fn(function* (ctx) { + yield* session.interrupt(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.message", + Effect.fn(function* (ctx) { + const message = yield* session.message(ctx.params) + if (message) return { data: message } + return yield* new MessageNotFoundError({ + sessionID: ctx.params.sessionID, + messageID: ctx.params.messageID, + message: `Message not found: ${ctx.params.messageID}`, + }) + }), + ) }), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 9f0cc83856a7..06203d3b592d 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -583,7 +583,7 @@ Reason: Compatibility: - No migration, synchronized event version, OpenAPI, or SDK regeneration is required. -- `sessions.events({ sessionID, after? })` remains a replay-and-tail stream of every durable event in aggregate sequence order. +- `sessions.events({ sessionID, after? })` replays every later durable event in aggregate sequence order, then tails raw live Session events. Ephemeral events never advance the recovery cursor. ## 2026-06-03: Sequential V2 Apply Patch Tool diff --git a/specs/v2/session.md b/specs/v2/session.md index 6529ff223262..e0e52396f188 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -165,17 +165,17 @@ Inbox promotion coalesces pending steers in durable admission order. Once contin Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. -The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. +The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail raw live Session events without a race. Live-only text, reasoning, and tool-input fragments appear only after the stream reaches its live boundary and never advance the recovery cursor. -The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers such as Discord publication. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. Until that contract is designed, connected renderers can combine `sessions.events(...)` with direct EventV2 delta subscriptions. +The first `sessions.events(...)` contract replays durable history and then tails the raw live Session stream, including ephemeral deltas while connected. The recovery cursor remains the greatest observed durable aggregate sequence: ephemeral fragments cannot advance it, replay after reconnect, or be mistaken for durable publication boundaries. The replay-to-live handoff subscribes before reading history, buffers concurrent live events until catch-up completes, deduplicates durable overlap, then flushes the remaining buffer in publication order. -Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state. +The internal durable-only `EventV2.durable(...)` tail uses advisory edge-triggered wakeups. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. The mixed `sessions.events(...)` follower instead buffers matching raw live events in publication order during replay; its bounded buffer terminates lagging consumers so they can reconnect from their last durable cursor rather than silently losing events. Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration. ## Current Tool Registry Slice -`ToolRegistry` is Location-scoped. Contributions are scoped replayable transforms: closing a contribution scope removes its definition and rebuilds the advertised catalog. Execution decodes input, optionally authorizes the call, invokes the retained handler, validates output, and settles failures as typed tool-result errors. +`ApplicationTools` stores process-scoped application registrations shared by all Locations. Each Location-scoped `ToolRegistry` overlays Location registrations, materializes definitions, and owns lookup and settlement. Closing a contribution scope removes its definition and rebuilds the advertised catalog. Trusted tool executors capture and perform authorization; the registry applies catalog visibility filtering, decodes input, invokes the retained handler, validates output, and settles failures as typed tool-result errors. When a Session omits `agent`, both execution and permission evaluation use the default `build` agent. A caller must not observe `build` model behavior while permission checks silently evaluate an empty no-agent policy. From 2b6e4add6351a3b4b50ab5a68cebd9b9761b3bd4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 10:41:30 -0400 Subject: [PATCH 2/9] test(opencode): cover session runtime routes --- .../test/server/httpapi-exercise/index.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d0ffe7f8f162..692a63defd31 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1066,6 +1066,31 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/event", "v2.session.events.missing") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/event", { sessionID: "ses_httpapi_missing" })}?after=0`, + headers: ctx.headers(), + })) + .status(404, undefined, "status"), + http.protected + .post("/api/session/{sessionID}/interrupt", "v2.session.interrupt") + .seeded((ctx) => ctx.session({ title: "Interrupt session" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/interrupt", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/message/{messageID}", "v2.session.message.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message/{messageID}", { + sessionID: "ses_httpapi_missing", + messageID: "msg_httpapi_missing", + }), + headers: ctx.headers(), + })) + .json(404, object, "status"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .seeded((ctx) => ctx.session({ title: "Invalid prompt owner" })) From 8cac3277d41d845cc532e8077e9685e72a73a202 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 13:06:40 -0400 Subject: [PATCH 3/9] refactor(sdk): keep session events durable --- CONTEXT.md | 6 +- packages/client/src/generated/types.ts | 129 +++++------------ packages/core/src/event.ts | 49 ------- packages/core/src/session.ts | 21 +-- packages/core/test/event.test.ts | 137 ------------------ packages/core/test/session-prompt.test.ts | 16 +- .../test/session-runner-tool-events.test.ts | 1 - packages/protocol/src/groups/session.ts | 8 +- packages/sdk-next/README.md | 2 +- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 6 +- 11 files changed, 54 insertions(+), 323 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 24f79caa4449..5a6227923922 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -149,14 +149,12 @@ _Avoid_: Response envelope - SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors. - The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names. - A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately. -- `sessions.events({ sessionID, after })` is a public raw Session event stream transported as SSE in both networked and embedded modes. It verifies the Session, replays durable events after the optional aggregate sequence, then continues with raw live events for that Session, including durable events and ephemeral text, reasoning, and tool-input fragments. -- A `sessions.events(...)` recovery cursor is the greatest observed `event.durable.seq`. Durable events carry their aggregate sequence in the existing raw event envelope; ephemeral events omit `durable` and never advance the cursor. Reconnecting with `after` replays only later durable events because ephemeral events are not recoverable. -- The replay-to-live handoff subscribes to live events before reading durable history, emits historical durable events first, buffers concurrent live events during catch-up, deduplicates buffered durable events already covered by replay, then flushes the remaining buffer in publication order before continuing live. +- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes. - `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state. - A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior. - The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API. - `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed. -- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold mixed event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; ephemeral events observed after that sequence are intentionally not replayed. Any reusable resume helper remains a separate API design question. +- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question. - The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented. - Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields. - A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor. diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 20a577951267..60ebfe40fafd 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -842,22 +842,6 @@ export type SessionsEventsOutput = readonly textID: string } } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.text.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly textID: string - readonly delta: string - } - } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined @@ -874,55 +858,6 @@ export type SessionsEventsOutput = readonly text: string } } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.reasoning.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly delta: string - } - } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly reasoningID: string - readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined - } - } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined @@ -939,22 +874,6 @@ export type SessionsEventsOutput = readonly name: string } } - | { - readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.tool.input.delta" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined - readonly data: { - readonly timestamp: number - readonly sessionID: string - readonly assistantMessageID: string - readonly callID: string - readonly delta: string - } - } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined @@ -1062,7 +981,7 @@ export type SessionsEventsOutput = | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.retried" + readonly type: "session.next.reasoning.started" readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined readonly location?: | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } @@ -1070,21 +989,15 @@ export type SessionsEventsOutput = readonly data: { readonly timestamp: number readonly sessionID: string - readonly attempt: number - readonly error: { - readonly message: string - readonly statusCode?: number | undefined - readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } | undefined - readonly responseBody?: string | undefined - readonly metadata?: { readonly [x: string]: string } | undefined - } + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined } } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.compaction.started" + readonly type: "session.next.reasoning.ended" readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined readonly location?: | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } @@ -1092,14 +1005,38 @@ export type SessionsEventsOutput = readonly data: { readonly timestamp: number readonly sessionID: string - readonly messageID: string - readonly reason: "auto" | "manual" + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: + | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } + | undefined + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number | undefined + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } | undefined + readonly responseBody?: string | undefined + readonly metadata?: { readonly [x: string]: string } | undefined + } } } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } | undefined - readonly type: "session.next.compaction.delta" + readonly type: "session.next.compaction.started" readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined readonly location?: | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } @@ -1108,7 +1045,7 @@ export type SessionsEventsOutput = readonly timestamp: number readonly sessionID: string readonly messageID: string - readonly text: string + readonly reason: "auto" | "manual" } } | { diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 052e7f83460a..132a88b11125 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -67,11 +67,6 @@ export interface Interface { readonly subscribe: (definition: D) => Stream.Stream> readonly all: () => Stream.Stream readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream - readonly follow: (input: { - readonly aggregateID: string - readonly after?: number - readonly matches: (event: Payload) => event is A - }) => Stream.Stream /** @deprecated Use `all()` and consume the returned stream. */ readonly listen: (listener: Subscriber) => Effect.Effect readonly project: (definition: D, projector: Subscriber) => Effect.Effect @@ -91,7 +86,6 @@ export class Service extends Context.Service()("@opencode/Ev export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect - readonly followerCapacity?: number } export const layerWith = (options?: LayerOptions) => @@ -552,48 +546,6 @@ export const layerWith = (options?: LayerOptions) => }) }) - const follow = (input: { - readonly aggregateID: string - readonly after?: number - readonly matches: (event: Payload) => event is A - }): Stream.Stream => - Stream.unwrap( - Effect.gen(function* () { - const pubsub = yield* PubSub.dropping(options?.followerCapacity ?? 1024) - const subscription = yield* PubSub.subscribe(pubsub) - const listener = (event: Payload) => - input.matches(event) - ? PubSub.publish(pubsub, event).pipe( - Effect.flatMap((published) => (published ? Effect.void : PubSub.shutdown(pubsub))), - ) - : Effect.void - yield* Effect.acquireRelease(listen(listener), (unsubscribe) => - unsubscribe.pipe(Effect.andThen(PubSub.shutdown(pubsub))), - ) - let sequence = input.after ?? -1 - const read: Effect.Effect> = Effect.suspend(() => - readAfter(input.aggregateID, sequence), - ).pipe( - Effect.map((events) => events.flatMap((event) => (input.matches(event) ? [event] : []))), - Effect.tap((events) => - Effect.sync(() => { - sequence = events.at(-1)?.durable?.seq ?? sequence - }), - ), - ) - const historical = yield* read - const live = Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => { - if (event.durable?.aggregateID !== input.aggregateID) return true - if (event.durable.seq <= sequence) return false - sequence = event.durable.seq - return true - }), - ) - return Stream.fromIterable(historical).pipe(Stream.concat(live)) - }), - ) - const project = (definition: D, projector: Subscriber): Effect.Effect => Effect.sync(() => { const list = projectors.get(definition.type) ?? [] @@ -606,7 +558,6 @@ export const layerWith = (options?: LayerOptions) => subscribe, all: streamAll, durable, - follow, listen, project, replay, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0f512acee05e..59c66b760a58 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -128,7 +128,7 @@ export interface Interface { readonly events: (input: { sessionID: SessionSchema.ID after?: number - }) => Stream.Stream + }) => Stream.Stream readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID @@ -184,10 +184,7 @@ export const layer = Layer.unwrap( const store = yield* SessionStore.Service const locations = yield* LocationServiceMap const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isSessionEvent = - (sessionID: SessionSchema.ID) => - (event: EventV2.Payload): event is SessionEvent.Event => - Schema.is(SessionEvent.All)(event) && event.data.sessionID === sessionID + const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( @@ -343,16 +340,10 @@ export const layer = Layer.unwrap( }), events: (input) => Stream.unwrap( - result.get(input.sessionID).pipe( - Effect.as( - events.follow({ - aggregateID: input.sessionID, - after: input.after, - matches: isSessionEvent(input.sessionID), - }), - ), - ), - ), + result + .get(input.sessionID) + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 12c9bf2db2bc..e2b2a5df046c 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -58,14 +58,6 @@ const GlobalMessage = EventV2.define({ }, }) -const LiveMessage = EventV2.define({ - type: "test.live", - schema: { - sessionID: Session.ID, - text: Schema.String, - }, -}) - const VersionedMessage = EventV2.define({ type: "test.versioned", durable: { @@ -426,135 +418,6 @@ describe("EventV2", () => { }), ) - it.effect("replays durable history before buffered matching live events", () => - Effect.gen(function* () { - const readStarted = yield* Deferred.make() - const continueRead = yield* Deferred.make() - let pause = true - const eventLayer = EventV2.layerWith({ - beforeAggregateRead: () => - pause - ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) - : Effect.void, - }).pipe(Layer.provide(Database.defaultLayer)) - - yield* Effect.gen(function* () { - const events = yield* EventV2.Service - const aggregateID = Session.ID.create() - yield* events.publish(DurableMessage, durableData(aggregateID, "before")) - const matches = ( - event: EventV2.Payload, - ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => - (event.type === DurableMessage.type || event.type === LiveMessage.type) && - typeof event.data === "object" && - event.data !== null && - "sessionID" in event.data && - event.data.sessionID === aggregateID - const fiber = yield* events - .follow({ aggregateID, matches }) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - yield* Deferred.await(readStarted) - - pause = false - yield* events.publish(DurableMessage, durableData(aggregateID, "during")) - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "live" }) - yield* Deferred.succeed(continueRead, undefined) - - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.type])).toEqual([ - [0, "message.removed"], - [1, "message.removed"], - [undefined, "test.live"], - ]) - }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) - }), - ) - - it.effect("preserves raw live publication order around durable events", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const aggregateID = Session.ID.create() - const matches = ( - event: EventV2.Payload, - ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => - (event.type === DurableMessage.type || event.type === LiveMessage.type) && - typeof event.data === "object" && - event.data !== null && - "sessionID" in event.data && - event.data.sessionID === aggregateID - const fiber = yield* events - .follow({ aggregateID, matches }) - .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped) - yield* Effect.yieldNow - - yield* events.publish(DurableMessage, durableData(aggregateID, "first")) - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "between" }) - yield* events.publish(DurableMessage, durableData(aggregateID, "second")) - - expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.type])).toEqual([ - [0, "message.removed"], - [undefined, "test.live"], - [1, "message.removed"], - ]) - }), - ) - - it.effect("terminates a lagging follower without blocking publishers", () => - Effect.gen(function* () { - const readStarted = yield* Deferred.make() - const continueRead = yield* Deferred.make() - const eventLayer = EventV2.layerWith({ - followerCapacity: 1, - beforeAggregateRead: () => - Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))), - }).pipe(Layer.provide(Database.defaultLayer)) - - yield* Effect.gen(function* () { - const events = yield* EventV2.Service - const aggregateID = Session.ID.create() - const matches = ( - event: EventV2.Payload, - ): event is EventV2.Payload & { readonly data: { readonly sessionID: Session.ID } } => - event.type === LiveMessage.type && - typeof event.data === "object" && - event.data !== null && - "sessionID" in event.data && - event.data.sessionID === aggregateID - const fiber = yield* events.follow({ aggregateID, matches }).pipe(Stream.runCollect, Effect.forkScoped) - yield* Deferred.await(readStarted) - - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "fills buffer" }) - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "closes follower" }) - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "publisher remains live" }) - yield* Deferred.succeed(continueRead, undefined) - - expect(Array.from(yield* Fiber.join(fiber)).length).toBeLessThanOrEqual(1) - }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) - }), - ) - - it.effect("removes a follower listener when its stream is interrupted", () => - Effect.gen(function* () { - const events = yield* EventV2.Service - const aggregateID = Session.ID.create() - let matches = 0 - const fiber = yield* events - .follow({ - aggregateID, - matches: (_event): _event is EventV2.Payload => { - matches++ - return false - }, - }) - .pipe(Stream.runDrain, Effect.forkScoped) - yield* Effect.yieldNow - yield* Fiber.interrupt(fiber) - - yield* events.publish(LiveMessage, { sessionID: aggregateID, text: "after interruption" }) - - expect(matches).toBe(0) - }), - ) - it.effect("coalesces durable aggregate wakes while draining every committed event", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b600673e8ae8..af30c4d87783 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -164,25 +164,18 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("replays durable Session events then streams raw live events", () => + it.effect("streams durable Session events after an aggregate sequence", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - const fiber = yield* session.events({ sessionID }).pipe(Stream.take(5), Stream.runCollect, Effect.forkScoped) + const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) - yield* events.publish(SessionEvent.Text.Delta, { - sessionID, - assistantMessageID: SessionMessage.ID.create(), - textID: "text_1", - timestamp: yield* DateTime.now, - delta: "live", - }) const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ @@ -190,11 +183,12 @@ describe("SessionV2.prompt", () => { [1, "session.next.prompt.admitted"], [2, "session.next.prompted"], [3, "session.next.prompted"], - [undefined, "session.next.text.delta"], ]) expect( Array.from( - yield* session.events({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect), + yield* session + .events({ sessionID, after: streamed[0]!.durable?.seq }) + .pipe(Stream.take(1), Stream.runCollect), ).map((event) => [event.durable?.seq, event.type]), ).toEqual([[1, "session.next.prompt.admitted"]]) }), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 9c4b182a0c91..f96ea4dea2b0 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -29,7 +29,6 @@ const capture = () => { subscribe: () => Stream.empty, all: () => Stream.empty, durable: () => Stream.empty, - follow: () => Stream.empty, listen: () => Effect.succeed(Effect.void), project: () => Effect.void, replay: () => Effect.void, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 610e85b4fecc..b09335f78187 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -33,9 +33,8 @@ const SessionsQueryFields = { search: Schema.optional(Schema.String), } -const SessionEventSchema: Schema.Codec = Schema.make( - SessionEvent.All.ast, -) +const SessionEventSchema: Schema.Codec = + Schema.make(SessionEvent.Durable.ast) const SessionsDirectoryQuery = Schema.Struct({ ...SessionsQueryFields, @@ -291,8 +290,7 @@ export const makeSessionGroup = (sessionLo OpenApi.annotations({ identifier: "v2.session.events", summary: "Subscribe to session events", - description: - "Replay durable events after an aggregate sequence, then continue with raw live Session events.", + description: "Replay durable events after an aggregate sequence, then continue with new durable events.", }), ), ) diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index 8a292471b63d..6a9e3bef3dc4 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -13,7 +13,7 @@ const session = yield * opencode.sessions.get({ sessionID }) It also exports `Tool` and exposes local-only `tools.register(...)`, replacing the former `@opencode-ai/core/public` facade. Registration uses Core's host-level `ApplicationTools` service shared by the host's Locations; each Location retains its own `ToolRegistry` for overlay, lookup, and settlement. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. -`sessions.events({ sessionID, after })` first replays durable events after the optional aggregate sequence, then emits raw live Session events, including ephemeral deltas. Only `event.durable.seq` advances the recovery cursor. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. +`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message. The same constructor is available as a service Layer: diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 06203d3b592d..9f0cc83856a7 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -583,7 +583,7 @@ Reason: Compatibility: - No migration, synchronized event version, OpenAPI, or SDK regeneration is required. -- `sessions.events({ sessionID, after? })` replays every later durable event in aggregate sequence order, then tails raw live Session events. Ephemeral events never advance the recovery cursor. +- `sessions.events({ sessionID, after? })` remains a replay-and-tail stream of every durable event in aggregate sequence order. ## 2026-06-03: Sequential V2 Apply Patch Tool diff --git a/specs/v2/session.md b/specs/v2/session.md index e0e52396f188..6b16dddee151 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -165,11 +165,11 @@ Inbox promotion coalesces pending steers in durable admission order. Once contin Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. -The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail raw live Session events without a race. Live-only text, reasoning, and tool-input fragments appear only after the stream reaches its live boundary and never advance the recovery cursor. +The synchronized `session.next.*` event family and projected Session-message model predate this branch. This slice refines their replay contract: projected Session messages retain their source aggregate sequence so canonical context ordering and `sessions.messages(...)` pagination follow durable event order even when caller-supplied IDs or timestamps do not. Consumers can use `sessions.events({ sessionID, after? })` to replay durable `session.next.*` events after an aggregate sequence cursor, then tail durable events without a race. Live-only text, reasoning, and tool-input fragments remain available through EventV2 subscriptions for connected renderers; they are intentionally absent from the replayable Session stream. -The first `sessions.events(...)` contract replays durable history and then tails the raw live Session stream, including ephemeral deltas while connected. The recovery cursor remains the greatest observed durable aggregate sequence: ephemeral fragments cannot advance it, replay after reconnect, or be mistaken for durable publication boundaries. The replay-to-live handoff subscribes before reading history, buffers concurrent live events until catch-up completes, deduplicates durable overlap, then flushes the remaining buffer in publication order. +The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. -The internal durable-only `EventV2.durable(...)` tail uses advisory edge-triggered wakeups. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. The mixed `sessions.events(...)` follower instead buffers matching raw live events in publication order during replay; its bounded buffer terminates lagging consumers so they can reconnect from their last durable cursor rather than silently losing events. +Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state. Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration. From 93aaa806b776419b512eb676d5e69104cc8f4a05 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 13:06:23 -0400 Subject: [PATCH 4/9] fix(core): drop failed turn continuation metadata --- CONTEXT.md | 13 + .../core/src/session/runner/to-llm-message.ts | 20 +- .../core/test/session-runner-message.test.ts | 72 ++++ packages/llm/ARCHITECTURE-QUESTIONS.md | 322 ++++++++++++++++++ 4 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 packages/llm/ARCHITECTURE-QUESTIONS.md diff --git a/CONTEXT.md b/CONTEXT.md index 5a6227923922..762a1ba7c2bf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,6 +57,16 @@ The bounded projection of a Core-executed tool result persisted in Session histo **Managed Tool Output File**: A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history. +**Model Request Options**: +Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request. +_Avoid_: Request body, wire options + +**Generation Controls**: +Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. + +**Native Continuation Metadata**: +Opaque protocol-shaped data attached to assistant content and required to continue that content natively with a compatible model, such as a reasoning signature or provider-hosted item identifier. + **PTY Environment**: The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. @@ -122,6 +132,9 @@ _Avoid_: Response envelope - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history. - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn. +- **Native Continuation Metadata** remains in durable history. Provider-turn projection includes it only for a successful exact originating provider/model match; failed turns and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility. +- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. +- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`. - Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs. - The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately. diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index f0ce7eef7f1b..d2b34b57871e 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,16 +70,26 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) + const reuseProviderMetadata = sameModel && message.finish !== "error" const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") return sameModel - ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] + ? [ + { + type: "reasoning", + text: item.text, + providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, + }, + ] : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] - const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) - const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) + const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + const result = toolResult( + item, + reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined, + ) return item.provider?.executed === true && result ? [call, result] : [call] }) const meaningful = content.filter((part) => { @@ -89,7 +99,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { }) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) - .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) + .map((item) => + toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), + ) .filter((message) => message !== undefined) .map(Message.tool) if (meaningful.length === 0) return results diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 653a192a6208..5798b665a86a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -327,6 +327,78 @@ Recent work ]) }) + test("drops provider-native continuation metadata from failed assistant turns", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-failed"), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + id: "reasoning-failed", + text: "Partial thought", + providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "hosted-failed", + name: "web_search", + provider: { + executed: true, + metadata: { openai: { itemId: "call_failed" } }, + resultMetadata: { openai: { itemId: "result_failed" } }, + }, + state: SessionMessage.ToolStateError.make({ + status: "error", + input: { query: "Effect" }, + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, + time: { created, completed: created }, + }), + ], + model, + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { + type: "tool-call", + id: "hosted-failed", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: undefined, + }, + { + type: "tool-result", + id: "hosted-failed", + name: "web_search", + result: { + type: "error", + value: { + error: { type: "unknown", message: "Provider turn interrupted" }, + content: [], + structured: {}, + }, + }, + providerExecuted: true, + cache: undefined, + metadata: undefined, + providerMetadata: undefined, + }, + ]) + }) + test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ diff --git a/packages/llm/ARCHITECTURE-QUESTIONS.md b/packages/llm/ARCHITECTURE-QUESTIONS.md new file mode 100644 index 000000000000..909a2c251f39 --- /dev/null +++ b/packages/llm/ARCHITECTURE-QUESTIONS.md @@ -0,0 +1,322 @@ +# LLM Architecture Questions + +Status: discussion agenda + +This document tracks the remaining architectural questions for the LLM package and its OpenCode integration. It is +intentionally narrower than `DESIGN.md`: each section should end in a concrete ownership rule, data shape, or law. + +## Initial Release Scope + +The first release targets OpenCode's internal V2 runtime. It does not attempt to be a complete general-purpose AI SDK. + +In scope: + +- Effect API only. +- Exactly one provider request per call. +- Provider-neutral request, response, event, usage, and error models. +- Translation between normalized data and provider-specific APIs. +- Provider configuration, endpoint construction, authentication, and transport. +- Returning normalized tool calls without executing them. +- Provider-specific continuation metadata required for later turns. +- Usage, cache usage, and estimated cost data needed by OpenCode. +- A clean boundary from OpenCode config/catalog models into executable models. +- Recorded real-provider tests covering supported models, capabilities, and failure modes. + +Out of scope: + +- Local tool execution. +- Automatic tool loops or multi-turn runs. +- Durable orchestration or persistence. +- Promise APIs. +- A polished external-consumer API. + +Release sequence: + +```text +1. Internal OpenCode integration +2. Public Effect API +3. Promise facade +``` + +A future complete-run layer can be built from the one-turn primitive after OpenCode validates the underlying data +model. The initial implementation should not anticipate it by adding orchestration now. + +## 1. Orchestration Ownership + +The initial package exposes only one provider turn: + +```ts +const result = yield * LLM.generate({ model, request }) +const events = LLM.stream({ model, request }) +``` + +OpenCode needs granular control over admission, persistence, permissions, tool settlement, interruption, compaction, +steering, and continuation. It cannot hand durable Session orchestration to an in-memory tool loop. + +Settled boundary: + +```text +LLM package owns + provider request lowering + transport + provider stream parsing + normalized turn events + +OpenCode owns + durable prompt admission + persisted history + permissions + durable tool execution and settlement + steering and queuing + compaction + provider-turn continuation + interruption and recovery policy +``` + +Laws: + +> `generate` and `stream` perform exactly one provider request and never execute a local tool or start another provider +> turn. + +> The package has no hidden conversation or run state between calls. + +Remaining questions: + +1. Which lifecycle events are stable enough for OpenCode to persist? +2. Where do retries live: transport, provider turn, or caller policy? +3. Can OpenCode reconstruct every continuation decision from turn inputs and outputs? + +## 2. Protocol-Native Continuation Data + +Providers return opaque data that may be required when continuing a conversation: + +- Anthropic reasoning signatures +- Bedrock reasoning signatures +- OpenAI encrypted reasoning content and item IDs +- Gemini thought signatures +- Provider-hosted tool identifiers and continuation handles + +The normalized message model currently carries this data in `providerMetadata`: + +```ts +const message = Message.assistant([ + { + type: "reasoning", + text: "...", + providerMetadata: { + anthropic: { + signature: "...", + }, + }, + }, +]) +``` + +### Namespace Is Not Provenance + +The `anthropic` key does not necessarily mean "returned by Anthropic." It identifies data understood by the Anthropic +Messages protocol adapter. Anthropic, an Anthropic-compatible provider, and a gateway translating to Anthropic +Messages may all read and write the same shape. + +The namespace answers: + +> Which protocol codec understands this data? + +It does not answer: + +> Which implementation produced it, and where can it be replayed safely? + +Those concerns remain separate, but they do not require a new envelope on each content part. The assistant message +already records its originating provider and model. The metadata key selects the adapter-specific shape, while the +message identity supplies the conservative compatibility check. + +The unsafe case is not only switching providers. Two models behind the same Anthropic-compatible endpoint have +incompatible thinking blocks: + +```text +Minimax response + -> persisted Anthropic-shaped signature + -> switch to Claude Haiku + -> signature is structurally present but invalid for Haiku +``` + +A protocol-namespace check is therefore insufficient. Anthropic also documents that signatures for the same model are +compatible across its direct API, Amazon Bedrock, and Vertex AI. Provider equality is consequently conservative rather +than a fundamental property of the signature. + +### Settled Initial Rule + +The initial OpenCode integration uses the identity already persisted on each assistant message: + +```ts +const sameModel = message.providerID === target.providerID && message.modelID === target.modelID +``` + +Request projection is fully automatic: + +```ts +const content = message.content.flatMap((part) => { + if (part.type !== "reasoning") return [part] + if (sameModel) return [part] + if (part.text === "") return [] + return [{ type: "text" as const, text: part.text }] +}) +``` + +- For an exact provider/model match, preserve the reasoning part and its `providerMetadata`. The selected protocol + adapter consumes the namespace it understands, such as `anthropic.signature`, `bedrock.signature`, or + `google.thoughtSignature`. +- For any provider or model change, omit all opaque continuation metadata from the transient request projection and + lower visible reasoning text to ordinary assistant text. +- Do not mutate durable history. A later switch back to the original provider/model can still use the preserved native + reasoning part and metadata. +- Do not add `routeID`, `protocolID`, `origin`, `replayScope`, or a compatibility envelope to each part for the initial + implementation. +- Do not expose an adaptation API for this case. Request projection owns the automatic compatibility rule. + +The provider namespace remains useful because it tells the selected adapter how to encode the opaque payload. It does +not independently authorize replay. + +This rule intentionally drops some documented-compatible continuations, including the same Claude model reached +through Anthropic and Bedrock. Broader compatibility may be added later as a model compatibility relation after +recorded cross-platform tests prove the exact identity mapping. It does not require changing the persisted part shape. + +Laws: + +> Native reasoning continuation metadata is projected only for an exact originating provider/model match. + +> A provider or model switch preserves non-empty visible reasoning as ordinary assistant text and projects no opaque +> continuation metadata. + +> Request projection never mutates the durable assistant message. + +> Metadata namespaces select encoding logic; they do not establish replay compatibility. + +## 3. Message Portability + +Provider switching affects more than opaque metadata: + +- Reasoning content may be encrypted, redacted, summarized, or absent. +- Hosted tool calls may have no portable local equivalent. +- Provider-specific content parts may not exist in another protocol. +- Tool-call IDs and assistant item IDs may have provider-specific constraints. +- Chronological system updates have different native support. + +Questions: + +1. What is the closed portable message algebra? +2. Which parts are portable semantics versus replay-only provider artifacts? +3. Does switching models lower history into the nearest portable representation? +4. How are dropped or transformed parts surfaced to the caller? +5. Can a continuation require the original provider even when the visible transcript is portable? + +## 4. Persistence Surface + +OpenCode must persist enough information to resume after process loss without persisting process-local behavior. + +Questions: + +1. Are normalized `TurnEvent`s the durable event contract, or only a streaming presentation? +2. Should callers persist events, assembled messages, `TurnResult`, or all three at different boundaries? +3. Which IDs are stable across streamed deltas and final assembled content? +4. Does the package provide a pure event reducer for rebuilding a `TurnResult`? +5. How are schema migrations handled for persisted provider metadata? + +Desired law: + +> Reducing a complete event stream produces the same result as `generate`. + +## 5. Tool Execution Boundary + +The package returns normalized tool calls but never executes them. OpenCode settles tools durably and constructs the +next request explicitly. + +Questions: + +1. What is the portable result of validating a streamed tool call? +2. How are provider-hosted tools represented without pretending they have local handlers? +3. Can tool calls or results contain provider-native metadata that constrains later replay? +4. Which tool-call IDs and argument shapes remain valid across provider changes? + +## 6. Request Mutation And Hooks + +The package needs typed common controls and escape hatches without making OpenCode duplicate provider logic. + +Questions: + +1. Which controls are portable generation semantics? +2. Which settings are provider-package inputs? +3. At what stage can hooks inspect or replace the provider-native body? +4. Are hooks allowed to change auth, transport, or protocol? +5. Which hook inputs and outputs are serializable and testable? +6. What ordering applies between model defaults, call options, hooks, and raw body/header overlays? + +## 7. Errors, Retries, And Interruption + +Questions: + +1. Which failures are safe to retry before any model-visible output? +2. Does a partial provider stream always terminate as a failed turn? +3. Can retry policy observe whether durable output has already been committed? +4. How does Effect interruption close HTTP/WebSocket resources and tool fibers? +5. Which failures become normalized provider-error events versus Effect failures? + +## 8. Usage, Cost, And Cache Accounting + +Questions: + +1. Is normalized usage sufficient for durable accounting, or must raw provider usage always be retained? +2. How are cached input reads/writes represented consistently? +3. Are estimated costs package output, catalog policy, or OpenCode-owned accounting? +4. How are multi-turn run totals computed when one turn lacks pricing or usage? +5. Does automatic cache placement belong to request preparation or run orchestration? + +## 9. Provider Compatibility Testing + +The package should distinguish itself through broad recorded tests against real provider APIs. HTTP recordings make +provider behavior reproducible without requiring live credentials in ordinary test runs. + +The matrix should cover: + +- Text generation and streaming. +- Tool calls and streamed tool arguments. +- Images and other claimed input modalities. +- Reasoning, signatures, encrypted content, and continuation replay. +- Prompt caching and cache usage. +- Authentication modes and custom endpoints. +- Context overflow and malformed requests. +- Rate limits, provider errors, and transport failures. +- Model/API combinations, including alternate APIs under one provider. +- Provider and model switching with persisted history. + +Workflow for a new model or provider API: + +```text +make representative live requests + -> record exact HTTP interactions + -> identify unsupported or malformed behavior + -> fix protocol/provider lowering + -> retain recordings as regression tests +``` + +Questions: + +1. What minimum scenario matrix must every provider satisfy? +2. Which volatile or sensitive fields must recordings redact or normalize? +3. When does a model inherit protocol coverage versus require its own recordings? +4. How are expected provider behavior changes reviewed and re-recorded? + +## Review Order + +Resolve these in dependency order: + +1. Portable message algebra +2. Provider metadata compatibility +3. Persistence surface +4. Hooks and mutation ordering +5. Errors, retries, and interruption +6. Usage, cost, and cache accounting +7. Provider compatibility test matrix + +Initial scope and orchestration ownership are settled. The next review should focus on message portability, provider +metadata, and persistence. From 9305264bf6b5df810ad20b0549721ff24f885b80 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 13:13:58 -0400 Subject: [PATCH 5/9] refactor: simplify session runtime contracts --- packages/core/src/session/runner/to-llm-message.ts | 7 ++++--- packages/protocol/src/groups/session.ts | 5 +---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index d2b34b57871e..b2b1af5d30f1 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,7 +70,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) - const reuseProviderMetadata = sameModel && message.finish !== "error" + const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") @@ -86,11 +86,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => { ? [{ type: "text", text: item.text }] : [] const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) + if (item.provider?.executed !== true) return [call] const result = toolResult( item, - reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined, + reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, ) - return item.provider?.executed === true && result ? [call, result] : [call] + return result ? [call, result] : [call] }) const meaningful = content.filter((part) => { if (part.type === "text") return part.text !== "" diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index b09335f78187..5e799091a97b 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -33,9 +33,6 @@ const SessionsQueryFields = { search: Schema.optional(Schema.String), } -const SessionEventSchema: Schema.Codec = - Schema.make(SessionEvent.Durable.ast) - const SessionsDirectoryQuery = Schema.Struct({ ...SessionsQueryFields, directory: AbsolutePath, @@ -282,7 +279,7 @@ export const makeSessionGroup = (sessionLo query: { after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), }, - success: HttpApiSchema.StreamSse({ data: SessionEventSchema }), + success: HttpApiSchema.StreamSse({ data: SessionEvent.Durable }), error: SessionNotFoundError, }) .middleware(sessionLocationMiddleware) From 5b289b3da80b1f14dcaf73318e472f552646849a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 13:38:34 -0400 Subject: [PATCH 6/9] chore(client): regenerate session types --- packages/client/src/generated/types.ts | 449 ++++++++++--------------- 1 file changed, 182 insertions(+), 267 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 60ebfe40fafd..858756e2a400 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -601,12 +601,10 @@ export type SessionsEventsInput = { export type SessionsEventsOutput = | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.agent.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -616,108 +614,90 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.model.switched" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly messageID: string - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.moved" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string - readonly location: { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - readonly subdirectory?: string | undefined + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.prompted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly messageID: string readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } readonly delivery: "steer" | "queue" } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.prompt.admitted" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly messageID: string readonly prompt: { readonly text: string - readonly files?: - | ReadonlyArray<{ - readonly uri: string - readonly mime: string - readonly name?: string | undefined - readonly description?: string | undefined - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined - readonly agents?: - | ReadonlyArray<{ - readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined - }> - | undefined + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> } readonly delivery: "steer" | "queue" } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.context.updated" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -727,12 +707,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.synthetic" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -742,12 +720,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.shell.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -758,12 +734,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.shell.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -773,29 +747,25 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.step.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly agent: string - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined } - readonly snapshot?: string | undefined + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.step.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -808,18 +778,16 @@ export type SessionsEventsOutput = readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } } - readonly snapshot?: string | undefined - readonly files?: ReadonlyArray | undefined + readonly snapshot?: string + readonly files?: ReadonlyArray } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.step.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -829,12 +797,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.text.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -844,12 +810,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.text.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -860,12 +824,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.input.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -876,12 +838,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.input.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -892,12 +852,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.called" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -907,140 +865,126 @@ export type SessionsEventsOutput = readonly input: { readonly [x: string]: unknown } readonly provider: { readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.progress" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: unknown } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | undefined } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.success" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: unknown } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | undefined } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray | undefined - readonly result?: unknown | undefined + readonly outputPaths?: ReadonlyArray + readonly result?: unknown readonly provider: { readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.tool.failed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly callID: string readonly error: { readonly type: "unknown"; readonly message: string } - readonly result?: unknown | undefined + readonly result?: unknown readonly provider: { readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.reasoning.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.reasoning.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string readonly reasoningID: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } | undefined + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.retried" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly attempt: number readonly error: { readonly message: string - readonly statusCode?: number | undefined + readonly statusCode?: number readonly isRetryable: boolean - readonly responseHeaders?: { readonly [x: string]: string } | undefined - readonly responseBody?: string | undefined - readonly metadata?: { readonly [x: string]: string } | undefined + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.compaction.started" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -1050,12 +994,10 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.compaction.ended" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string @@ -1067,50 +1009,42 @@ export type SessionsEventsOutput = } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.revert.staged" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number readonly sessionID: string readonly revert: { readonly messageID: string - readonly partID?: string | undefined - readonly snapshot?: string | undefined - readonly diff?: string | undefined - readonly files?: - | ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" - readonly additions: number - readonly deletions: number - readonly patch: string - }> - | undefined + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> } } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.revert.cleared" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number; readonly sessionID: string } } | { readonly id: string - readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.next.revert.committed" - readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined - readonly location?: - | { readonly directory: string; readonly workspaceID?: string | undefined | undefined } - | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } } @@ -1127,39 +1061,39 @@ export type SessionsMessageOutput = { readonly data: | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "agent-switched" readonly agent: string } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "model-switched" - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly text: string readonly files?: ReadonlyArray<{ readonly uri: string readonly mime: string - readonly name?: string | null - readonly description?: string | null - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly agents?: ReadonlyArray<{ readonly name: string - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly type: "user" } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly sessionID: string readonly text: string @@ -1167,15 +1101,15 @@ export type SessionsMessageOutput = { } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "system" readonly text: string } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null - readonly time: { readonly created: number; readonly completed?: number | null } + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" readonly callID: string readonly command: string @@ -1183,18 +1117,18 @@ export type SessionsMessageOutput = { } | { readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null - readonly time: { readonly created: number; readonly completed?: number | null } + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number; readonly completed?: number } readonly type: "assistant" readonly agent: string - readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null } + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly content: ReadonlyArray< | { readonly type: "text"; readonly id: string; readonly text: string } | { readonly type: "reasoning" readonly id: string readonly text: string - readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } } | { readonly type: "tool" @@ -1202,23 +1136,18 @@ export type SessionsMessageOutput = { readonly name: string readonly provider?: { readonly executed: boolean - readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null - readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null - } | null + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } readonly state: | { readonly status: "pending"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: JsonValue } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > } | { @@ -1227,61 +1156,47 @@ export type SessionsMessageOutput = { readonly attachments?: ReadonlyArray<{ readonly uri: string readonly mime: string - readonly name?: string | null - readonly description?: string | null - readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null - }> | null + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray | null - readonly structured: { readonly [x: string]: any } - readonly result?: JsonValue | null + readonly outputPaths?: ReadonlyArray + readonly structured: { readonly [x: string]: JsonValue } + readonly result?: JsonValue } | { readonly status: "error" readonly input: { readonly [x: string]: JsonValue } readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } - | { - readonly type: "file" - readonly uri: string - readonly mime: string - readonly name?: string | null - } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly structured: { readonly [x: string]: any } + readonly structured: { readonly [x: string]: JsonValue } readonly error: { readonly type: "unknown"; readonly message: string } - readonly result?: JsonValue | null + readonly result?: JsonValue } readonly time: { readonly created: number - readonly ran?: number | null - readonly completed?: number | null - readonly pruned?: number | null + readonly ran?: number + readonly completed?: number + readonly pruned?: number } } > - readonly snapshot?: { - readonly start?: string | null - readonly end?: string | null - readonly files?: ReadonlyArray | null - } | null - readonly finish?: string | null - readonly cost?: number | null + readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } + readonly finish?: string + readonly cost?: number readonly tokens?: { readonly input: number readonly output: number readonly reasoning: number readonly cache: { readonly read: number; readonly write: number } - } | null - readonly error?: { readonly type: "unknown"; readonly message: string } | null + } + readonly error?: { readonly type: "unknown"; readonly message: string } } | { readonly type: "compaction" @@ -1289,7 +1204,7 @@ export type SessionsMessageOutput = { readonly summary: string readonly recent: string readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } } }["data"] From 6a2396b29decd2f6b40cf7044213bee6ef87d3bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 13:51:21 -0400 Subject: [PATCH 7/9] fix(schema): preserve event codec requirements --- packages/schema/src/event.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index da1cadfa7b69..b90828587384 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -49,7 +49,7 @@ export function define< readonly aggregate: string } readonly schema: Fields -}): Schema.Schema>>> & Definition> { +}): Schema.Codec>>, unknown> & Definition> { const data = Schema.Struct(input.schema) return Object.assign( Schema.Struct({ @@ -65,7 +65,7 @@ export function define< ...(input.durable === undefined ? {} : { durable: input.durable }), data, }, - ) as Schema.Schema>>> & Definition> + ) as Schema.Codec>>, unknown> & Definition> } export function inventory>(...definitions: Definitions) { From d8a5a471fc378b031a4bae1aefec0426f4071941 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 14:11:57 -0400 Subject: [PATCH 8/9] refactor(sdk): simplify embedded HTTP bridge --- packages/schema/src/event.ts | 35 ++++++++++++++------------- packages/sdk-next/src/opencode.ts | 40 +++++++++++++++++++------------ 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index b90828587384..3bd0da9894c2 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -41,7 +41,7 @@ export type Payload = { export function define< const Type extends string, - Fields extends Readonly>>, + const Fields extends Readonly>>, >(input: { readonly type: Type readonly durable?: { @@ -49,23 +49,24 @@ export function define< readonly aggregate: string } readonly schema: Fields -}): Schema.Codec>>, unknown> & Definition> { +}) { const data = Schema.Struct(input.schema) - return Object.assign( - Schema.Struct({ - id: ID, - metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), - type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), - location: optional(Location.Ref), - data, - }).annotate({ identifier: input.type }), - { - type: input.type, - ...(input.durable === undefined ? {} : { durable: input.durable }), - data, - }, - ) as Schema.Codec>>, unknown> & Definition> + return Schema.Struct({ + id: ID, + metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), + type: Schema.Literal(input.type), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + location: optional(Location.Ref), + data, + }) + .annotate({ identifier: input.type }) + .pipe( + statics(() => ({ + type: input.type, + ...(input.durable === undefined ? {} : { durable: input.durable }), + data, + })), + ) satisfies Definition } export function inventory>(...definitions: Definitions) { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index ce282e18b8d0..5256548efc6c 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -2,27 +2,37 @@ import { OpenCode } from "@opencode-ai/client/effect" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, Scope } from "effect" +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const applicationTools = ApplicationTools.layer - const services = Layer.merge(applicationTools, PermissionSaved.defaultLayer) - const context = yield* Layer.build(services) - const web = HttpRouter.toWebHandler( - createEmbeddedRoutes().pipe(Layer.provide(Layer.succeedContext(context)), Layer.provide(HttpServer.layerServices)), - { disableLogger: true }, + const scope = yield* Scope.Scope + const memoMap = yield* Layer.makeMemoMap + const context = yield* Layer.buildWithMemoMap( + Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer), + memoMap, + scope, ) - yield* Effect.addFinalizer(() => Effect.promise(web.dispose)) const tools = Context.get(context, ApplicationTools.Service) - const httpClient = HttpClient.make((request, _url, signal) => - Effect.gen(function* () { - const input = yield* HttpClientRequest.toWeb(request, { signal }).pipe(Effect.orDie) - return HttpClientResponse.fromWeb(request, yield* Effect.promise(() => web.handler(input, context))) - }), + const permissions = Context.get(context, PermissionSaved.Service) + const web = yield* Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true, memoMap }, + ), + ), + (web) => Effect.promise(web.dispose), ) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + preconnect: () => undefined, + }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FetchHttpClient.Fetch, fetch), ) return { ...client, From 0044d603c7eda74ba84384662a930b2b7718ac11 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 25 Jun 2026 14:12:03 -0400 Subject: [PATCH 9/9] docs(llm): remove unrelated architecture agenda --- packages/llm/ARCHITECTURE-QUESTIONS.md | 322 ------------------------- 1 file changed, 322 deletions(-) delete mode 100644 packages/llm/ARCHITECTURE-QUESTIONS.md diff --git a/packages/llm/ARCHITECTURE-QUESTIONS.md b/packages/llm/ARCHITECTURE-QUESTIONS.md deleted file mode 100644 index 909a2c251f39..000000000000 --- a/packages/llm/ARCHITECTURE-QUESTIONS.md +++ /dev/null @@ -1,322 +0,0 @@ -# LLM Architecture Questions - -Status: discussion agenda - -This document tracks the remaining architectural questions for the LLM package and its OpenCode integration. It is -intentionally narrower than `DESIGN.md`: each section should end in a concrete ownership rule, data shape, or law. - -## Initial Release Scope - -The first release targets OpenCode's internal V2 runtime. It does not attempt to be a complete general-purpose AI SDK. - -In scope: - -- Effect API only. -- Exactly one provider request per call. -- Provider-neutral request, response, event, usage, and error models. -- Translation between normalized data and provider-specific APIs. -- Provider configuration, endpoint construction, authentication, and transport. -- Returning normalized tool calls without executing them. -- Provider-specific continuation metadata required for later turns. -- Usage, cache usage, and estimated cost data needed by OpenCode. -- A clean boundary from OpenCode config/catalog models into executable models. -- Recorded real-provider tests covering supported models, capabilities, and failure modes. - -Out of scope: - -- Local tool execution. -- Automatic tool loops or multi-turn runs. -- Durable orchestration or persistence. -- Promise APIs. -- A polished external-consumer API. - -Release sequence: - -```text -1. Internal OpenCode integration -2. Public Effect API -3. Promise facade -``` - -A future complete-run layer can be built from the one-turn primitive after OpenCode validates the underlying data -model. The initial implementation should not anticipate it by adding orchestration now. - -## 1. Orchestration Ownership - -The initial package exposes only one provider turn: - -```ts -const result = yield * LLM.generate({ model, request }) -const events = LLM.stream({ model, request }) -``` - -OpenCode needs granular control over admission, persistence, permissions, tool settlement, interruption, compaction, -steering, and continuation. It cannot hand durable Session orchestration to an in-memory tool loop. - -Settled boundary: - -```text -LLM package owns - provider request lowering - transport - provider stream parsing - normalized turn events - -OpenCode owns - durable prompt admission - persisted history - permissions - durable tool execution and settlement - steering and queuing - compaction - provider-turn continuation - interruption and recovery policy -``` - -Laws: - -> `generate` and `stream` perform exactly one provider request and never execute a local tool or start another provider -> turn. - -> The package has no hidden conversation or run state between calls. - -Remaining questions: - -1. Which lifecycle events are stable enough for OpenCode to persist? -2. Where do retries live: transport, provider turn, or caller policy? -3. Can OpenCode reconstruct every continuation decision from turn inputs and outputs? - -## 2. Protocol-Native Continuation Data - -Providers return opaque data that may be required when continuing a conversation: - -- Anthropic reasoning signatures -- Bedrock reasoning signatures -- OpenAI encrypted reasoning content and item IDs -- Gemini thought signatures -- Provider-hosted tool identifiers and continuation handles - -The normalized message model currently carries this data in `providerMetadata`: - -```ts -const message = Message.assistant([ - { - type: "reasoning", - text: "...", - providerMetadata: { - anthropic: { - signature: "...", - }, - }, - }, -]) -``` - -### Namespace Is Not Provenance - -The `anthropic` key does not necessarily mean "returned by Anthropic." It identifies data understood by the Anthropic -Messages protocol adapter. Anthropic, an Anthropic-compatible provider, and a gateway translating to Anthropic -Messages may all read and write the same shape. - -The namespace answers: - -> Which protocol codec understands this data? - -It does not answer: - -> Which implementation produced it, and where can it be replayed safely? - -Those concerns remain separate, but they do not require a new envelope on each content part. The assistant message -already records its originating provider and model. The metadata key selects the adapter-specific shape, while the -message identity supplies the conservative compatibility check. - -The unsafe case is not only switching providers. Two models behind the same Anthropic-compatible endpoint have -incompatible thinking blocks: - -```text -Minimax response - -> persisted Anthropic-shaped signature - -> switch to Claude Haiku - -> signature is structurally present but invalid for Haiku -``` - -A protocol-namespace check is therefore insufficient. Anthropic also documents that signatures for the same model are -compatible across its direct API, Amazon Bedrock, and Vertex AI. Provider equality is consequently conservative rather -than a fundamental property of the signature. - -### Settled Initial Rule - -The initial OpenCode integration uses the identity already persisted on each assistant message: - -```ts -const sameModel = message.providerID === target.providerID && message.modelID === target.modelID -``` - -Request projection is fully automatic: - -```ts -const content = message.content.flatMap((part) => { - if (part.type !== "reasoning") return [part] - if (sameModel) return [part] - if (part.text === "") return [] - return [{ type: "text" as const, text: part.text }] -}) -``` - -- For an exact provider/model match, preserve the reasoning part and its `providerMetadata`. The selected protocol - adapter consumes the namespace it understands, such as `anthropic.signature`, `bedrock.signature`, or - `google.thoughtSignature`. -- For any provider or model change, omit all opaque continuation metadata from the transient request projection and - lower visible reasoning text to ordinary assistant text. -- Do not mutate durable history. A later switch back to the original provider/model can still use the preserved native - reasoning part and metadata. -- Do not add `routeID`, `protocolID`, `origin`, `replayScope`, or a compatibility envelope to each part for the initial - implementation. -- Do not expose an adaptation API for this case. Request projection owns the automatic compatibility rule. - -The provider namespace remains useful because it tells the selected adapter how to encode the opaque payload. It does -not independently authorize replay. - -This rule intentionally drops some documented-compatible continuations, including the same Claude model reached -through Anthropic and Bedrock. Broader compatibility may be added later as a model compatibility relation after -recorded cross-platform tests prove the exact identity mapping. It does not require changing the persisted part shape. - -Laws: - -> Native reasoning continuation metadata is projected only for an exact originating provider/model match. - -> A provider or model switch preserves non-empty visible reasoning as ordinary assistant text and projects no opaque -> continuation metadata. - -> Request projection never mutates the durable assistant message. - -> Metadata namespaces select encoding logic; they do not establish replay compatibility. - -## 3. Message Portability - -Provider switching affects more than opaque metadata: - -- Reasoning content may be encrypted, redacted, summarized, or absent. -- Hosted tool calls may have no portable local equivalent. -- Provider-specific content parts may not exist in another protocol. -- Tool-call IDs and assistant item IDs may have provider-specific constraints. -- Chronological system updates have different native support. - -Questions: - -1. What is the closed portable message algebra? -2. Which parts are portable semantics versus replay-only provider artifacts? -3. Does switching models lower history into the nearest portable representation? -4. How are dropped or transformed parts surfaced to the caller? -5. Can a continuation require the original provider even when the visible transcript is portable? - -## 4. Persistence Surface - -OpenCode must persist enough information to resume after process loss without persisting process-local behavior. - -Questions: - -1. Are normalized `TurnEvent`s the durable event contract, or only a streaming presentation? -2. Should callers persist events, assembled messages, `TurnResult`, or all three at different boundaries? -3. Which IDs are stable across streamed deltas and final assembled content? -4. Does the package provide a pure event reducer for rebuilding a `TurnResult`? -5. How are schema migrations handled for persisted provider metadata? - -Desired law: - -> Reducing a complete event stream produces the same result as `generate`. - -## 5. Tool Execution Boundary - -The package returns normalized tool calls but never executes them. OpenCode settles tools durably and constructs the -next request explicitly. - -Questions: - -1. What is the portable result of validating a streamed tool call? -2. How are provider-hosted tools represented without pretending they have local handlers? -3. Can tool calls or results contain provider-native metadata that constrains later replay? -4. Which tool-call IDs and argument shapes remain valid across provider changes? - -## 6. Request Mutation And Hooks - -The package needs typed common controls and escape hatches without making OpenCode duplicate provider logic. - -Questions: - -1. Which controls are portable generation semantics? -2. Which settings are provider-package inputs? -3. At what stage can hooks inspect or replace the provider-native body? -4. Are hooks allowed to change auth, transport, or protocol? -5. Which hook inputs and outputs are serializable and testable? -6. What ordering applies between model defaults, call options, hooks, and raw body/header overlays? - -## 7. Errors, Retries, And Interruption - -Questions: - -1. Which failures are safe to retry before any model-visible output? -2. Does a partial provider stream always terminate as a failed turn? -3. Can retry policy observe whether durable output has already been committed? -4. How does Effect interruption close HTTP/WebSocket resources and tool fibers? -5. Which failures become normalized provider-error events versus Effect failures? - -## 8. Usage, Cost, And Cache Accounting - -Questions: - -1. Is normalized usage sufficient for durable accounting, or must raw provider usage always be retained? -2. How are cached input reads/writes represented consistently? -3. Are estimated costs package output, catalog policy, or OpenCode-owned accounting? -4. How are multi-turn run totals computed when one turn lacks pricing or usage? -5. Does automatic cache placement belong to request preparation or run orchestration? - -## 9. Provider Compatibility Testing - -The package should distinguish itself through broad recorded tests against real provider APIs. HTTP recordings make -provider behavior reproducible without requiring live credentials in ordinary test runs. - -The matrix should cover: - -- Text generation and streaming. -- Tool calls and streamed tool arguments. -- Images and other claimed input modalities. -- Reasoning, signatures, encrypted content, and continuation replay. -- Prompt caching and cache usage. -- Authentication modes and custom endpoints. -- Context overflow and malformed requests. -- Rate limits, provider errors, and transport failures. -- Model/API combinations, including alternate APIs under one provider. -- Provider and model switching with persisted history. - -Workflow for a new model or provider API: - -```text -make representative live requests - -> record exact HTTP interactions - -> identify unsupported or malformed behavior - -> fix protocol/provider lowering - -> retain recordings as regression tests -``` - -Questions: - -1. What minimum scenario matrix must every provider satisfy? -2. Which volatile or sensitive fields must recordings redact or normalize? -3. When does a model inherit protocol coverage versus require its own recordings? -4. How are expected provider behavior changes reviewed and re-recorded? - -## Review Order - -Resolve these in dependency order: - -1. Portable message algebra -2. Provider metadata compatibility -3. Persistence surface -4. Hooks and mutation ordering -5. Errors, retries, and interruption -6. Usage, cost, and cache accounting -7. Provider compatibility test matrix - -Initial scope and orchestration ownership are settled. The next review should focus on message portability, provider -metadata, and persistence.