mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-09 21:58:28 +00:00
feat(sdk): restore session runtime operations (#33777)
This commit is contained in:
parent
44806777ca
commit
f44423609b
20 changed files with 1100 additions and 93 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
17
CONTEXT.md
17
CONTEXT.md
|
|
@ -57,11 +57,21 @@ 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.
|
||||
|
||||
**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**:
|
||||
|
|
@ -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.
|
||||
|
|
@ -159,7 +172,7 @@ _Avoid_: Response envelope
|
|||
- 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?
|
||||
|
|
|
|||
|
|
@ -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<RawClient["server.session"]["session.events"]>[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<RawClient["server.session"]["session.interrupt"]>[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<RawClient["server.session"]["session.message"]>[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"]) })
|
||||
|
|
|
|||
|
|
@ -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<SessionsEventsOutput> =>
|
||||
sse<SessionsEventsOutput>(
|
||||
{
|
||||
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<SessionsInterruptOutput>(
|
||||
{
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -592,3 +592,619 @@ 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 }
|
||||
readonly type: "session.next.agent.switched"
|
||||
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 agent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.model.switched"
|
||||
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 }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.moved"
|
||||
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 }
|
||||
readonly subdirectory?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.prompted"
|
||||
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
|
||||
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 }
|
||||
readonly type: "session.next.prompt.admitted"
|
||||
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
|
||||
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 }
|
||||
readonly type: "session.next.context.updated"
|
||||
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 text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.synthetic"
|
||||
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 text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.shell.started"
|
||||
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 callID: string
|
||||
readonly command: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.shell.ended"
|
||||
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 callID: string
|
||||
readonly output: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.started"
|
||||
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 }
|
||||
readonly snapshot?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.ended"
|
||||
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 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
|
||||
readonly files?: ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.step.failed"
|
||||
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 error: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.text.started"
|
||||
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 textID: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.text.ended"
|
||||
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 textID: string
|
||||
readonly text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.input.started"
|
||||
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 name: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.input.ended"
|
||||
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 text: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.called"
|
||||
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 tool: string
|
||||
readonly input: { readonly [x: string]: unknown }
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.progress"
|
||||
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]: unknown }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.success"
|
||||
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]: unknown }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
readonly result?: unknown
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.tool.failed"
|
||||
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
|
||||
readonly provider: {
|
||||
readonly executed: boolean
|
||||
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.reasoning.started"
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.reasoning.ended"
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.retried"
|
||||
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
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string }
|
||||
readonly responseBody?: string
|
||||
readonly metadata?: { readonly [x: string]: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.compaction.started"
|
||||
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 reason: "auto" | "manual"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.compaction.ended"
|
||||
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 reason: "auto" | "manual"
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.revert.staged"
|
||||
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
|
||||
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 }
|
||||
readonly type: "session.next.revert.cleared"
|
||||
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 }
|
||||
readonly type: "session.next.revert.committed"
|
||||
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 }
|
||||
}
|
||||
|
||||
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 }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
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 }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
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
|
||||
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 type: "user"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly sessionID: string
|
||||
readonly text: string
|
||||
readonly type: "synthetic"
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
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 }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly type: "shell"
|
||||
readonly callID: string
|
||||
readonly command: string
|
||||
readonly output: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
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 }
|
||||
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 } }
|
||||
}
|
||||
| {
|
||||
readonly type: "tool"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly provider?: {
|
||||
readonly executed: boolean
|
||||
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]: JsonValue }
|
||||
readonly content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
}
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly attachments?: 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 content: ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
readonly outputPaths?: ReadonlyArray<string>
|
||||
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 }
|
||||
>
|
||||
readonly structured: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: "unknown"; readonly message: string }
|
||||
readonly result?: JsonValue
|
||||
}
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly ran?: number
|
||||
readonly completed?: number
|
||||
readonly pruned?: number
|
||||
}
|
||||
}
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<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 error?: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
}
|
||||
}["data"]
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,17 +70,28 @@ 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.error === undefined
|
||||
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)
|
||||
return item.provider?.executed === true && result ? [call, result] : [call]
|
||||
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,
|
||||
)
|
||||
return result ? [call, result] : [call]
|
||||
})
|
||||
const meaningful = content.filter((part) => {
|
||||
if (part.type === "text") return part.text !== ""
|
||||
|
|
@ -89,7 +100,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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ type Slot = {
|
|||
|
||||
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
|
||||
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
|
||||
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
|
||||
const Manifest = Schema.fromJsonString(Schema.Array(Schema.String))
|
||||
const manifestName = ".httpapi-codegen.json"
|
||||
|
||||
|
|
@ -125,9 +126,10 @@ export function compile<Id extends string, Groups extends HttpApiGroup.Any>(
|
|||
...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<Group>) {
|
|||
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<readonly [stri
|
|||
if (!isStreamSchema(schema)) return [[path, schema]]
|
||||
if (schema._tag === "StreamUint8Array") return []
|
||||
const value = schema.sseMode === "data" ? streamDataSchema(schema) : schema.events
|
||||
const rebuilt =
|
||||
schema.sseMode === "data"
|
||||
? HttpApiSchema.StreamSse({ data: value, error: schema.error, contentType: schema.contentType })
|
||||
: HttpApiSchema.StreamSse({
|
||||
events: schema.events,
|
||||
error: schema.error,
|
||||
contentType: schema.contentType,
|
||||
})
|
||||
if (!sameEncoding(schema.events.ast, rebuilt.events.ast)) {
|
||||
throw new GenerationError({ reason: `Unportable schema: ${path}.${schema.sseMode}` })
|
||||
}
|
||||
return [
|
||||
[`${path}.${schema.sseMode}`, value],
|
||||
[`${path}.error`, schema.error],
|
||||
|
|
@ -964,11 +955,33 @@ function isStreamSchema(schema: Schema.Top): schema is HttpApiSchema.StreamSchem
|
|||
}
|
||||
|
||||
function streamDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
|
||||
const ast = Schema.toType(schema.events).ast
|
||||
return Schema.make(streamDataAst(Schema.toType(schema.events).ast))
|
||||
}
|
||||
|
||||
function streamEncodedDataSchema(schema: Extract<HttpApiSchema.StreamSchema, { readonly _tag: "StreamSse" }>) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" }))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -272,6 +273,54 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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: SessionEvent.Durable }),
|
||||
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 new durable 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",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export type Payload<D extends Definition = Definition> = {
|
|||
|
||||
export function define<
|
||||
const Type extends string,
|
||||
Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||
const Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
|
||||
>(input: {
|
||||
readonly type: Type
|
||||
readonly durable?: {
|
||||
|
|
@ -49,23 +49,24 @@ export function define<
|
|||
readonly aggregate: string
|
||||
}
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
}) {
|
||||
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.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>>
|
||||
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<Type, typeof data>
|
||||
}
|
||||
|
||||
export function inventory<const Definitions extends ReadonlyArray<Definition>>(...definitions: Definitions) {
|
||||
|
|
|
|||
|
|
@ -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 })` 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -2,43 +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 { Cause, Context, Effect, Layer } from "effect"
|
||||
import {
|
||||
HttpClient,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerError,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} 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 { 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 scope = yield* Scope.Scope
|
||||
const memoMap = yield* Layer.makeMemoMap
|
||||
const context = yield* Layer.buildWithMemoMap(
|
||||
Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer),
|
||||
memoMap,
|
||||
scope,
|
||||
)
|
||||
const tools = Context.get(context, ApplicationTools.Service)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ Eager local-tool execution is intentionally unbounded in the current local slice
|
|||
|
||||
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 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 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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ Event replay owner claims are separate from clustered Session execution ownershi
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue