Compare commits

...

15 Commits

Author SHA1 Message Date
Kit Langton a211114c8b Merge remote-tracking branch 'origin/dev' into httpapi-codegen 2026-06-23 17:22:24 -04:00
Kit Langton 845c0d47f7 refactor(sdk): extract shared schema and embedded host 2026-06-23 17:22:11 -04:00
Kit Langton bbf1021d4a refactor(client): extract model and protocol 2026-06-23 16:12:56 -04:00
Kit Langton 602c470d37 refactor(client): stabilize datatype names 2026-06-23 15:30:44 -04:00
Kit Langton 9ad36251a7 refactor(client): use canonical session schemas 2026-06-23 15:26:53 -04:00
Kit Langton 7e8f35e29e refactor(client): simplify code generation 2026-06-23 14:18:49 -04:00
Kit Langton 22ee85671b refactor(client): simplify embedded setup 2026-06-23 13:18:36 -04:00
Kit Langton 51e5e4186f feat(client): add embedded OpenCode layer 2026-06-23 13:03:20 -04:00
Kit Langton 9c20973501 refactor(client): generate from hosted session group 2026-06-23 12:53:58 -04:00
Kit Langton 07a65d5a16 fix(client): preserve V2 session contracts 2026-06-23 12:33:28 -04:00
Kit Langton 905cd60017 Merge remote-tracking branch 'origin/dev' into httpapi-codegen
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/server/src/groups/session.ts
2026-06-23 12:01:37 -04:00
Kit Langton e03f5dd331 feat(client): add embedded V2 host 2026-06-23 11:56:06 -04:00
Kit Langton 924e00776b feat(client): generate public session API 2026-06-23 09:00:51 -04:00
Kit Langton 66bcddd059 feat(sdk): add Promise client codegen 2026-06-22 22:10:48 -04:00
Kit Langton b241e38cc8 feat(sdk): add Effect HttpApi codegen 2026-06-22 16:48:04 -04:00
109 changed files with 5813 additions and 1091 deletions
+5
View File
@@ -69,6 +69,11 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
+77
View File
@@ -67,6 +67,21 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
**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.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -117,6 +132,51 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **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.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server and code generation consume the same hosted `SessionGroup`. Codegen may assign a separate consumer-facing group name without reconstructing group membership or endpoint contracts.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against the canonical Protocol `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns the authoritative Session `HttpApi`. The server and client generator consume the exact hosted `SessionGroup`, including `compact`, `wait`, and `context`.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- 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.
- `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.
- 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.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?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -133,6 +193,23 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Replace the transitional server-internal Session location middleware tag when the remaining location-scoped groups move to Protocol or gain a narrow request-location service contract.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
+84
View File
@@ -109,6 +109,27 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/client": {
"name": "@opencode-ai/client",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
],
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.9",
@@ -277,6 +298,7 @@
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -476,6 +498,18 @@
"effect": "4.0.0-beta.83",
},
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.9",
@@ -651,6 +685,30 @@
"@opentui/solid",
],
},
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"@noble/hashes": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/script": {
"name": "@opencode-ai/script",
"dependencies": {
@@ -661,6 +719,20 @@
"@types/semver": "^7.5.8",
},
},
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.9",
@@ -681,6 +753,7 @@
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -944,6 +1017,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.12",
"@noble/hashes": "2.2.0",
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
@@ -1774,6 +1848,8 @@
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1800,14 +1876,22 @@
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
+1
View File
@@ -34,6 +34,7 @@
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@noble/hashes": "2.2.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
+27
View File
@@ -0,0 +1,27 @@
# @opencode-ai/client
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
## Entrypoints
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, `prompt`, `compact`, `wait`, and `context`. The server and generator consume the exact same hosted `SessionGroup`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. The authoritative `SessionGroup` lives in `@opencode-ai/protocol`; Server hosts that exact group and adapts it to Core.
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
Effect consumers construct canonical decoded inputs:
```ts
import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect"
const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" })
yield *
client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
})
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
```
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
},
"scripts": {
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.83"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url"
const contract = compile(HttpApi.make("opencode-client").add(SessionGroup), {
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise(
Effect.all(
[
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write(
emitEffectImported(contract, {
module: "@opencode-ai/protocol/session",
group: "SessionGroup",
}),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
),
],
{ concurrency: 2, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
+12
View File
@@ -0,0 +1,12 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Prompt } from "@opencode-ai/schema/prompt"
@@ -0,0 +1,5 @@
[
"client-error.ts",
"client.ts",
"index.ts"
]
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -0,0 +1,136 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { ClientError } from "./client-error"
const Api = HttpApi.make("generated").add(SessionGroup)
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint0_0Input = {
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"]
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"]
readonly directory?: Endpoint0_0Request["query"]["directory"]
readonly project?: Endpoint0_0Request["query"]["project"]
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
}
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
raw["session.list"]({
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"]
}
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_3Input = {
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
readonly agent: Endpoint0_3Request["payload"]["agent"]
}
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly model: Endpoint0_4Request["payload"]["model"]
}
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly id?: Endpoint0_5Request["payload"]["id"]
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
readonly resume?: Endpoint0_5Request["payload"]["resume"]
}
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.prompt"]({
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw),
create: Endpoint0_1(raw),
get: Endpoint0_2(raw),
switchAgent: Endpoint0_3(raw),
switchModel: Endpoint0_4(raw),
prompt: Endpoint0_5(raw),
compact: Endpoint0_6(raw),
wait: Endpoint0_7(raw),
context: Endpoint0_8(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
@@ -0,0 +1,2 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -0,0 +1,6 @@
[
"client-error.ts",
"client.ts",
"index.ts",
"types.ts"
]
@@ -0,0 +1,11 @@
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
export class ClientError extends Error {
override readonly name = "ClientError"
constructor(
readonly reason: ClientErrorReason,
options?: ErrorOptions,
) {
super(reason, options)
}
}
+309
View File
@@ -0,0 +1,309 @@
import type {
SessionsListInput,
SessionsListOutput,
SessionsCreateInput,
SessionsCreateOutput,
SessionsGetInput,
SessionsGetOutput,
SessionsSwitchAgentInput,
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
SessionsCompactOutput,
SessionsWaitInput,
SessionsWaitOutput,
SessionsContextInput,
SessionsContextOutput,
} from "./types"
import { ClientError } from "./client-error"
export interface ClientOptions {
readonly baseUrl: string
readonly fetch?: typeof globalThis.fetch
readonly headers?: HeadersInit
}
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: HeadersInit
}
interface RequestDescriptor {
readonly method: string
readonly path: string
readonly query?: Record<string, unknown>
readonly headers?: Record<string, unknown>
readonly body?: unknown
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
}
export function make(options: ClientOptions) {
const fetch = options.fetch ?? globalThis.fetch
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
const url = new URL(descriptor.path, options.baseUrl)
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
const headers = new Headers(options.headers)
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
if (value !== undefined && value !== null) headers.set(key, String(value))
}
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
return {
url,
init: {
method: descriptor.method,
signal: requestOptions?.signal,
headers,
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
} satisfies RequestInit,
}
}
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
try {
const prepared = prepare(descriptor, requestOptions)
return await fetch(prepared.url, prepared.init)
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
}
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
if (descriptor.empty) {
try {
await response.body?.cancel()
} catch {}
return undefined as A
}
return (await json(response)) as A
}
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
async *[Symbol.asyncIterator]() {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
if (!isContentType(response, "text/event-stream")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
if (response.body === null) throw new ClientError("MalformedResponse")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
let next
try {
next = await reader.read()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (trailingCarriageReturn) buffer += "\r"
if (next.done && buffer !== "") buffer += "\n\n"
let boundary = buffer.indexOf("\n\n")
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = block
.split("\n")
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
.join("\n")
if (data !== "") {
try {
yield JSON.parse(data) as A
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
boundary = buffer.indexOf("\n\n")
}
if (next.done) return
}
} finally {
try {
await reader.cancel()
} catch {}
reader.releaseLock()
}
},
})
return {
sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>(
{
method: "GET",
path: `/api/session`,
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsCreateOutput }>(
{
method: "POST",
path: `/api/session`,
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200,
declaredStatuses: [400, 404, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchAgentOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input.agent },
successStatus: 204,
declaredStatuses: [400, 404, 401],
empty: true,
},
requestOptions,
),
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchModelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input.model },
successStatus: 204,
declaredStatuses: [400, 404, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
successStatus: 200,
declaredStatuses: [409, 400, 404, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
request<SessionsCompactOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
request<SessionsWaitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsContextOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200,
declaredStatuses: [404, 401, 500, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
}
}
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
if (value === undefined || value === null) return
if (Array.isArray(value)) {
for (const item of value) appendQuery(params, key, item)
return
}
if (typeof value === "object") {
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
return
}
params.append(key, String(value))
}
async function json(response: Response): Promise<unknown> {
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
let text: string
try {
text = await response.text()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (text === "") throw new ClientError("MalformedResponse")
try {
return JSON.parse(text)
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
function isContentType(response: Response, expected: string) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
}
+3
View File
@@ -0,0 +1,3 @@
export { ClientError, type ClientErrorReason } from "./client-error"
export * as OpenCode from "./client"
export * from "./types"
+555
View File
@@ -0,0 +1,555 @@
export type JsonValue =
| null
| boolean
| number
| string
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue }
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
export type InvalidRequestError = {
readonly _tag: "InvalidRequestError"
readonly message: string
readonly kind?: string | undefined
readonly field?: string | undefined
}
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
export type SessionNotFoundError = {
readonly _tag: "SessionNotFoundError"
readonly sessionID: string
readonly message: string
}
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
readonly ref?: string | undefined
}
export const isUnknownError = (value: unknown): value is UnknownError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
export type SessionsListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["search"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["cursor"]
}
export type SessionsListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}>
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
export type SessionsCreateInput = {
readonly id?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["id"]
readonly agent?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["agent"]
readonly model?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["model"]
readonly location?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["location"]
}
export type SessionsCreateOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}
}["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}
}["data"]
export type SessionsSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
}
export type SessionsSwitchAgentOutput = void
export type SessionsSwitchModelInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly model: {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
}["model"]
}
export type SessionsSwitchModelOutput = void
export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | undefined
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" | undefined
readonly resume?: boolean | undefined
}["id"]
readonly prompt: {
readonly id?: string | undefined
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" | undefined
readonly resume?: boolean | undefined
}["prompt"]
readonly delivery?: {
readonly id?: string | undefined
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" | undefined
readonly resume?: boolean | undefined
}["delivery"]
readonly resume?: {
readonly id?: string | undefined
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" | undefined
readonly resume?: boolean | undefined
}["resume"]
}
export type SessionsPromptOutput = {
readonly data: {
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly prompt: {
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 delivery: "steer" | "queue"
readonly timeCreated: number
readonly promotedSeq?: number | null
}
}["data"]
export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCompactOutput = void
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsWaitOutput = void
export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsContextOutput = {
readonly data: ReadonlyArray<
| {
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<string> | 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 } | 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"]
+1
View File
@@ -0,0 +1 @@
export * from "./generated/index"
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location as CoreLocation } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { SessionGroup as ServerSessionGroup } from "@opencode-ai/server/groups/session"
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
expect(CoreLocation.Ref).toBe(Location.Ref)
expect(ModelV2.Ref).toBe(Model.Ref)
expect(SessionV2.Info).toBe(Session.Info)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt)
expect(ServerSessionGroup).toBe(SessionGroup)
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.ID.fromExternal({ namespace: "opencord.agent-thread", key: "thread-1" })).toMatch(/^ses_[a-f0-9]{64}$/)
expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
})
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test"
import { DateTime, Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
test("sessions.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
})
test("session methods retain decoded Effect inputs and outputs", async () => {
const httpClient = HttpClient.make((request) => {
const url = request.url
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 (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 })
const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.sessions.switchModel({
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
})
const admitted = yield* client.sessions.prompt({
sessionID: Session.ID.make("ses_test"),
prompt: Prompt.make({ text: "Hello" }),
resume: false,
})
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 }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
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([])
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { realpathSync } from "node:fs"
import { mkdtemp, rm } from "node:fs/promises"
import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
const schema = resolve(import.meta.dir, "../../schema")
const protocol = resolve(import.meta.dir, "../../protocol")
const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
})
})
async function bundleInputs(specifier: string, target: "browser" | "bun") {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
const child = Bun.spawn(
[
process.execPath,
"build",
entrypoint,
`--target=${target}`,
"--format=esm",
"--packages=bundle",
`--metafile=${metafile}`,
`--outdir=${join(temporary, "out")}`,
],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function within(inputs: ReadonlyArray<string>, directory: string) {
const prefix = directory.endsWith(sep) ? directory : directory + sep
return inputs.filter((input) => input === directory || input.startsWith(prefix))
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src"
test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
"http://localhost:3000/api/session/ses_test",
)
return Response.json(session)
},
})
const result = await client.sessions.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
})
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push({ url, init })
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
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" } })
},
})
const page = await client.sessions.list({ limit: "10", order: "desc" })
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
const admitted = await client.sessions.prompt({
sessionID: "ses_test",
prompt: { text: "Hello" },
resume: false,
})
await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" })
expect(page.cursor.next).toBe("next")
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
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"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["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"],
])
const body = requests[4]?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" },
resume: false,
})
})
test("middleware errors remain declared client errors", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
})
try {
await client.sessions.create({})
throw new Error("Expected request to fail")
} catch (error) {
expect(isUnauthorizedError(error)).toBe(true)
}
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
+1
View File
@@ -91,6 +91,7 @@
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
+3 -2
View File
@@ -1,13 +1,14 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Array, Context, Effect, Layer, Schema, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export const ID = Agent.ID
export type ID = typeof ID.Type
export const defaultID = ID.make("build")
+82 -26
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import { Context, Data, Effect, Layer, LayerMap, Scope } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
@@ -48,12 +48,48 @@ import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
class LocationRefKey extends Data.Class<{
readonly directory: Location.Ref["directory"]
readonly workspaceID: Location.Ref["workspaceID"] | null
}> {}
const locationRefKey = (ref: Location.Ref) =>
new LocationRefKey({ directory: ref.directory, workspaceID: ref.workspaceID ?? null })
const locationServiceDependencies = [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
] as const
class LocationServiceCache extends LayerMap.Service<LocationServiceCache>()("@opencode/example/LocationServiceCache", {
lookup: (ref: ReturnType<typeof locationRefKey>) => {
const locationRef = Location.Ref.make({
directory: ref.directory,
...(ref.workspaceID === null ? {} : { workspaceID: ref.workspaceID }),
})
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
Effect.logInfo("booting location services", {
directory: locationRef.directory,
workspaceID: locationRef.workspaceID,
}),
)
const location = Location.layer(ref)
const location = Location.layer(locationRef)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
@@ -123,25 +159,45 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
],
dependencies: [...locationServiceDependencies, ApplicationTools.layer],
}) {}
type LocationServices = Layer.Success<ReturnType<typeof LocationServiceCache.get>>
export interface LocationServiceMapService {
readonly get: (ref: Location.Ref) => Layer.Layer<LocationServices>
readonly contextEffect: (ref: Location.Ref) => Effect.Effect<Context.Context<LocationServices>, never, Scope.Scope>
readonly invalidate: (ref: Location.Ref) => Effect.Effect<void>
}
const locationServiceMapLayer = <E, R>(
service: Context.Service<LocationServiceMap, LocationServiceMapService>,
cache: Layer.Layer<LocationServiceCache, E, R>,
) =>
Layer.effect(
service,
Effect.map(LocationServiceCache, (locations) =>
service.of({
get: (ref) => locations.get(locationRefKey(ref)),
contextEffect: (ref) => locations.contextEffect(locationRefKey(ref)),
invalidate: (ref) => locations.invalidate(locationRefKey(ref)),
}),
),
).pipe(Layer.provide(cache))
export class LocationServiceMap extends Context.Service<LocationServiceMap, LocationServiceMapService>()(
"@opencode/example/LocationServiceMap",
) {
static readonly get = (ref: Location.Ref) =>
Layer.unwrap(Effect.map(LocationServiceMap, (locations) => locations.get(ref)))
static readonly contextEffect = (ref: Location.Ref) =>
Effect.flatMap(LocationServiceMap, (locations) => locations.contextEffect(ref))
static readonly invalidate = (ref: Location.Ref) =>
Effect.flatMap(LocationServiceMap, (locations) => locations.invalidate(ref))
static readonly layer: Layer.Layer<LocationServiceMap> = locationServiceMapLayer(this, LocationServiceCache.layer)
static readonly layerWithApplicationTools: Layer.Layer<LocationServiceMap, never, ApplicationTools.Service> =
locationServiceMapLayer(
this,
LocationServiceCache.layerNoDeps.pipe(Layer.provide(Layer.mergeAll(...locationServiceDependencies))),
)
}
+3 -4
View File
@@ -1,14 +1,13 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Location as SchemaLocation } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export class Ref extends Schema.Class<Ref>("Location.Ref")({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}) {}
export const Ref = SchemaLocation.Ref
export type Ref = SchemaLocation.Ref
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
+4 -7
View File
@@ -1,11 +1,12 @@
import { Schema, Types } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export const ID = Model.ID
export type ID = typeof ID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export const VariantID = Model.VariantID
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
@@ -33,11 +34,7 @@ export const Cost = Schema.Struct({
}),
})
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderV2.ID,
variant: VariantID.pipe(Schema.optional),
})
export const Ref = Model.Ref
export type Ref = typeof Ref.Type
export const Api = Schema.Union([
+3 -7
View File
@@ -1,14 +1,10 @@
export * as ProjectSchema from "./schema"
import { Schema } from "effect"
import { AbsolutePath, withStatics } from "../schema"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "../schema"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export const ID = Project.ID
export type ID = typeof ID.Type
export const Vcs = Schema.Union([
+2 -18
View File
@@ -1,25 +1,9 @@
export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema, Types } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export const ID = Provider.ID
export type ID = typeof ID.Type
export const AISDK = Schema.Struct({
+1 -1
View File
@@ -28,7 +28,7 @@ const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.provide(LocationServiceMap.layerWithApplicationTools.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
+22 -58
View File
@@ -1,47 +1,27 @@
import { Option, Schema, SchemaGetter } from "effect"
import { Hash } from "./util/hash"
import { Schema } from "effect"
import {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
type ExternalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
RelativePath,
withStatics,
} from "@opencode-ai/schema/schema"
export type ExternalID = {
readonly namespace: string
readonly key: string
export {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
RelativePath,
withStatics,
}
export const externalID = (prefix: string, input: ExternalID) =>
`${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}`
/**
* Integer greater than zero.
*/
export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
/**
* Integer greater than or equal to zero.
*/
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
/**
* Relative file path (e.g., `src/components/Button.tsx`).
*/
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = Schema.Schema.Type<typeof RelativePath>
/**
* Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`).
*/
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = Schema.Schema.Type<typeof AbsolutePath>
/**
* Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
*/
export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
Schema.optionalKey(schema).pipe(
Schema.decodeTo(Schema.optional(schema), {
decode: SchemaGetter.passthrough({ strict: false }),
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
}),
)
export type { ExternalID }
/**
* Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
@@ -71,22 +51,6 @@ export type DeepMutable<T> = T extends string | number | boolean | bigint | symb
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
/**
* Attach static methods to a schema object. Designed to be used with `.pipe()`:
*
* @example
* export const Foo = fooSchema.pipe(
* withStatics((schema) => ({
* zero: schema.make(0),
* from: Schema.decodeUnknownOption(schema),
* }))
* )
*/
export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))
/**
* Nominal wrapper for scalar types. The class itself is a valid schema —
* pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc.
+2 -5
View File
@@ -2,6 +2,7 @@ export * as SessionV2 from "./session"
export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { Session as SchemaSession } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
@@ -38,11 +39,7 @@ import { SessionInput } from "./session/input"
// - by subpath
// - by workspace (home is special)
export const ListAnchor = Schema.Struct({
id: SessionSchema.ID,
time: Schema.Finite,
direction: Schema.Literals(["previous", "next"]),
})
export const ListAnchor = SchemaSession.ListAnchor
export type ListAnchor = typeof ListAnchor.Type
const ListInputBase = {
+2 -4
View File
@@ -2,12 +2,10 @@ import { Schema } from "effect"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { V2Schema } from "../v2-schema"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { Location } from "../location"
import { RelativePath } from "../schema"
import { SessionMessageID } from "./message-id"
export { FileAttachment }
@@ -22,7 +20,7 @@ export const Source = Schema.Struct({
export type Source = typeof Source.Type
const Base = {
timestamp: V2Schema.DateTimeUtcFromMillis,
timestamp: DateTimeUtcFromMillis,
sessionID: SessionSchema.ID,
}
const PromptFields = {
+6 -14
View File
@@ -2,10 +2,9 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull, lte } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { SessionInput as SchemaSessionInput } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { NonNegativeInt } from "../schema"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Prompt } from "./prompt"
@@ -14,24 +13,17 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export const Delivery = Schema.Literals(["steer", "queue"])
export const Delivery = SchemaSessionInput.Delivery
export type Delivery = typeof Delivery.Type
export class Admitted extends Schema.Class<Admitted>("SessionInput.Admitted")({
admittedSeq: NonNegativeInt,
id: SessionMessage.ID,
sessionID: SessionSchema.ID,
prompt: Prompt,
delivery: Delivery,
timeCreated: V2Schema.DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(Schema.optional),
}) {}
export const Admitted = SchemaSessionInput.Admitted
export type Admitted = SchemaSessionInput.Admitted
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
new Admitted({
Admitted.make({
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
@@ -76,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
event.durable === undefined
? Effect.die("Prompt admission event is missing aggregate sequence")
: Effect.succeed(
new Admitted({
Admitted.make({
admittedSeq: event.durable.seq,
id: input.id,
sessionID: input.sessionID,
+1 -12
View File
@@ -1,13 +1,2 @@
export * as SessionMessageID from "./message-id"
import { Schema } from "effect"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
Schema.brand("Session.Message.ID"),
withStatics((schema) => ({
create: () => schema.make("msg_" + Identifier.ascending()),
})),
)
export type ID = typeof ID.Type
export { ID } from "@opencode-ai/schema/session-message"
+15 -15
View File
@@ -102,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => {
return adapter.appendMessage(
new SessionMessage.AgentSwitched({
SessionMessage.AgentSwitched.make({
id: event.data.messageID,
type: "agent-switched",
metadata: event.metadata,
@@ -113,7 +113,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.model.switched": (event) => {
return adapter.appendMessage(
new SessionMessage.ModelSwitched({
SessionMessage.ModelSwitched.make({
id: event.data.messageID,
type: "model-switched",
metadata: event.metadata,
@@ -125,7 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.moved": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
new SessionMessage.User({
SessionMessage.User.make({
id: event.data.messageID,
type: "user",
metadata: event.metadata,
@@ -139,7 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.prompt.admitted": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
new SessionMessage.System({
SessionMessage.System.make({
id: event.data.messageID,
type: "system",
text: event.data.text,
@@ -148,7 +148,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
),
"session.next.synthetic": (event) => {
return adapter.appendMessage(
new SessionMessage.Synthetic({
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
text: event.data.text,
id: event.data.messageID,
@@ -159,7 +159,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.shell.started": (event) => {
return adapter.appendMessage(
new SessionMessage.Shell({
SessionMessage.Shell.make({
id: event.data.messageID,
type: "shell",
metadata: event.metadata,
@@ -194,7 +194,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
}
yield* adapter.appendMessage(
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: event.data.assistantMessageID,
type: "assistant",
agent: event.data.agent,
@@ -225,7 +225,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })),
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
})
},
@@ -245,12 +245,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
}),
),
)
@@ -270,7 +270,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
match.provider = event.data.provider
match.time.ran = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateRunning({
SessionMessage.ToolStateRunning.make({
status: "running",
input: event.data.input,
structured: {},
@@ -300,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
match.time.completed = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateCompleted({
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: match.state.input,
structured: event.data.structured,
@@ -323,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
match.time.completed = event.data.timestamp
match.state = castDraft(
new SessionMessage.ToolStateError({
SessionMessage.ToolStateError.make({
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
@@ -339,7 +339,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
new SessionMessage.AssistantReasoning({
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: event.data.reasoningID,
text: "",
@@ -369,7 +369,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.next.compaction.delta": () => Effect.void,
"session.next.compaction.ended": (event) => {
return adapter.appendMessage(
new SessionMessage.Compaction({
SessionMessage.Compaction.make({
id: event.data.messageID,
type: "compaction",
metadata: event.metadata,
+1 -192
View File
@@ -1,193 +1,2 @@
export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { ModelV2 } from "../model"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
import { SessionMessageID } from "./message-id"
export const ID = SessionMessageID.ID
export type ID = typeof ID.Type
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
}),
}
export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
...Base,
type: Schema.Literal("agent-switched"),
agent: SessionEvent.AgentSwitched.data.fields.agent,
}) {}
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
...Base,
type: Schema.Literal("model-switched"),
model: ModelV2.Ref,
}) {}
export class User extends Schema.Class<User>("Session.Message.User")({
...Base,
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
type: Schema.Literal("user"),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
}),
}) {}
export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
...Base,
sessionID: SessionEvent.Synthetic.data.fields.sessionID,
text: SessionEvent.Synthetic.data.fields.text,
type: Schema.Literal("synthetic"),
}) {}
export class System extends Schema.Class<System>("Session.Message.System")({
...Base,
type: Schema.Literal("system"),
text: SessionEvent.ContextUpdated.data.fields.text,
}) {}
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
...Base,
type: Schema.Literal("shell"),
callID: SessionEvent.Shell.Started.data.fields.callID,
command: SessionEvent.Shell.Started.data.fields.command,
output: Schema.String,
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}) {}
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Message.ToolState.Pending")({
status: Schema.Literal("pending"),
input: Schema.String,
}) {}
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: Schema.Record(Schema.String, Schema.Any),
content: ToolContent.pipe(Schema.Array),
}) {}
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolContent.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: Schema.Record(Schema.String, Schema.Any),
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
error: SessionEvent.UnknownError,
result: SessionEvent.Tool.Failed.data.fields.result,
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
Schema.toTaggedUnion("status"),
)
export type ToolState = Schema.Schema.Type<typeof ToolState>
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.Assistant.Tool")({
type: Schema.Literal("tool"),
id: Schema.String,
name: Schema.String,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
resultMetadata: ProviderMetadata.pipe(Schema.optional),
}).pipe(Schema.optional),
state: ToolState,
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}) {}
export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
type: Schema.Literal("text"),
id: Schema.String,
text: Schema.String,
}) {}
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Message.Assistant.Reasoning")({
type: Schema.Literal("reasoning"),
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
}) {}
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Schema.toTaggedUnion("type"),
)
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistant")({
...Base,
type: Schema.Literal("assistant"),
agent: Schema.String,
model: SessionEvent.Step.Started.data.fields.model,
content: AssistantContent.pipe(Schema.Array),
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
end: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
finish: Schema.String.pipe(Schema.optional),
cost: Schema.Finite.pipe(Schema.optional),
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}).pipe(Schema.optional),
error: SessionEvent.Step.Failed.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}) {}
export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
recent: Schema.String,
...Base,
}) {}
export const Message = Schema.Union([
AgentSwitched,
ModelSwitched,
User,
Synthetic,
System,
Shell,
Assistant,
Compaction,
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" })
export type Message = Schema.Schema.Type<typeof Message>
export type Type = Message["type"]
export * from "@opencode-ai/schema/session-message"
+1 -46
View File
@@ -1,46 +1 @@
import * as Schema from "effect/Schema"
export class Source extends Schema.Class<Source>("Prompt.Source")({
start: Schema.Finite,
end: Schema.Finite,
text: Schema.String,
}) {}
export class FileAttachment extends Schema.Class<FileAttachment>("Prompt.FileAttachment")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {
static create(input: FileAttachment) {
return new FileAttachment({
uri: input.uri,
mime: input.mime,
name: input.name,
description: input.description,
source: input.source,
})
}
}
export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.AgentAttachment")({
name: Schema.String,
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Prompt")({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
}) {
static readonly equivalence = Schema.toEquivalence(Prompt)
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
return new Prompt({
text: input.text,
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
})
}
}
export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
+6 -44
View File
@@ -1,49 +1,11 @@
export * as SessionSchema from "./schema"
import { Schema } from "effect"
import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { externalID, type ExternalID, RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { V2Schema } from "../v2-schema"
import { AgentV2 } from "../agent"
import { Session } from "@opencode-ai/schema/session"
import type { ExternalID } from "../schema"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => {
const create = () => schema.make("ses_" + Identifier.descending())
return {
create,
descending: (id?: string) => (id === undefined ? create() : schema.make(id)),
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
}
}),
)
export const ID = Session.ID
export type ID = typeof ID.Type
export type { ExternalID }
export class Info extends Schema.Class<Info>("SessionV2.Info")({
id: ID,
parentID: ID.pipe(optionalOmitUndefined),
projectID: ProjectV2.ID,
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
}) {}
export const Info = Session.Info
export type Info = Session.Info
-10
View File
@@ -1,10 +0,0 @@
import { DateTime, Schema, SchemaGetter } from "effect"
export const DateTimeUtcFromMillis = Schema.Finite.pipe(
Schema.decodeTo(Schema.DateTimeUtc, {
decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
}),
)
export * as V2Schema from "./v2-schema"
+2 -14
View File
@@ -1,18 +1,6 @@
export * as WorkspaceV2 from "./workspace"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Workspace } from "@opencode-ai/schema/workspace"
export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(
Schema.brand("WorkspaceV2.ID"),
withStatics((schema) => ({
ascending: (id?: string) => {
if (!id) return schema.make("wrk_" + Identifier.ascending())
if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`)
return schema.make(id)
},
create: () => schema.make("wrk_" + Identifier.ascending()),
})),
)
export const ID = Workspace.ID
export type ID = typeof ID.Type
+2 -3
View File
@@ -4,9 +4,8 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, DateTimeUtcFromMillis } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { V2Schema } from "@opencode-ai/core/v2-schema"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@@ -79,7 +78,7 @@ const SyncTimestamp = EventV2.define({
},
schema: {
id: Schema.String,
timestamp: V2Schema.DateTimeUtcFromMillis,
timestamp: DateTimeUtcFromMillis,
},
})
+20 -10
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
@@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
LocationServiceMap.layer.pipe(
LocationServiceMap.layerWithApplicationTools.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
@@ -52,14 +52,24 @@ const it = testEffect(
)
describe("LocationServiceMap", () => {
it.effect("compares equivalent location refs by value", () =>
Effect.sync(() => {
const directory = AbsolutePath.make("/project")
expect(Equal.equals(Location.Ref.make({ directory }), Location.Ref.make({ directory }))).toBe(true)
expect(Hash.hash(Location.Ref.make({ directory }))).toBe(
Hash.hash(Location.Ref.make({ directory, workspaceID: undefined })),
)
}),
it.live("reuses cached services for equivalent location refs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.scoped(
Effect.gen(function* () {
const locations = yield* LocationServiceMap
const directory = AbsolutePath.make(dir.path)
const first = yield* locations.contextEffect(Location.Ref.make({ directory }))
const second = yield* locations.contextEffect(Location.Ref.make({ directory, workspaceID: undefined }))
expect(first).toBe(second)
}),
),
),
),
)
it.live("isolates location state while sharing location policy with catalog", () =>
+2 -2
View File
@@ -218,7 +218,7 @@ describe("SessionV2.create", () => {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false })
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
expect(
@@ -238,7 +238,7 @@ describe("SessionV2.create", () => {
const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location })
const admitted = yield* session.prompt({
sessionID: created.id,
prompt: new Prompt({ text: "Replay lifecycle" }),
prompt: Prompt.make({ text: "Replay lifecycle" }),
resume: false,
})
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
+10 -10
View File
@@ -36,7 +36,7 @@ const assistantRow = (
id: _,
type,
...data
} = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time }))
} = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
@@ -69,7 +69,7 @@ describe("SessionProjector", () => {
sessionID,
messageID: SessionMessage.ID.make("msg_first"),
timestamp: created,
prompt: new Prompt({ text: "first" }),
prompt: Prompt.make({ text: "first" }),
delivery: "steer",
},
{ id: EventV2.ID.make("evt_z") },
@@ -80,7 +80,7 @@ describe("SessionProjector", () => {
sessionID,
messageID: SessionMessage.ID.make("msg_second"),
timestamp: created,
prompt: new Prompt({ text: "second" }),
prompt: Prompt.make({ text: "second" }),
delivery: "steer",
},
{ id: EventV2.ID.make("evt_a") },
@@ -145,7 +145,7 @@ describe("SessionProjector", () => {
const admitted = yield* SessionInput.admit(db, events, {
id,
sessionID,
prompt: new Prompt({ text: "promote me" }),
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
@@ -154,7 +154,7 @@ describe("SessionProjector", () => {
sessionID,
timestamp: admitted.timeCreated,
messageID: id,
prompt: new Prompt({ text: "promote me" }),
prompt: Prompt.make({ text: "promote me" }),
delivery: "steer",
})
@@ -334,7 +334,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
Effect.gen(function* () {
const stale = new SessionMessage.Assistant({
const stale = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
@@ -342,7 +342,7 @@ describe("SessionProjector", () => {
content: [],
time: { created },
})
const completed = new SessionMessage.Assistant({
const completed = SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
@@ -466,15 +466,15 @@ describe("SessionProjector", () => {
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }),
)
expect(messages).toEqual([
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
model,
content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
+28 -23
View File
@@ -147,7 +147,7 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
resume: false,
})
@@ -171,8 +171,8 @@ describe("SessionV2.prompt", () => {
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
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)
const streamed = Array.from(yield* Fiber.join(fiber))
@@ -198,7 +198,7 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const message = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
resume: false,
})
@@ -217,7 +217,7 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false }
const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false }
const first = yield* session.prompt(input)
const second = yield* session.prompt(input)
@@ -235,7 +235,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
resume: false,
}
@@ -255,7 +255,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Recover committed prompt" }),
prompt: Prompt.make({ text: "Recover committed prompt" }),
resume: false,
}
const first = yield* session.prompt(input)
@@ -276,13 +276,13 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
})
const failure = yield* session
.prompt({
sessionID,
id: messageID,
prompt: new Prompt({ text: "Delete the failing tests" }),
prompt: Prompt.make({ text: "Delete the failing tests" }),
resume: false,
})
.pipe(Effect.flip)
@@ -301,14 +301,14 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
resume: false,
})
const failure = yield* session
.prompt({
id: messageID,
sessionID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
delivery: "queue",
resume: false,
})
@@ -325,7 +325,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: new Prompt({ text: "Fix the failing tests" }),
prompt: Prompt.make({ text: "Fix the failing tests" }),
resume: false,
}
@@ -344,7 +344,7 @@ describe("SessionV2.prompt", () => {
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false })
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
yield* Effect.all(
[
@@ -368,9 +368,9 @@ describe("SessionV2.prompt", () => {
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
const cutoff = first.admittedSeq
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
@@ -386,7 +386,12 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
wakeCalls.length = 0
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false })
yield* session.prompt({
id: messageID,
sessionID,
prompt: Prompt.make({ text: "Replay pending" }),
resume: false,
})
const recorded = yield* db
.select()
.from(EventTable)
@@ -422,7 +427,7 @@ describe("SessionV2.prompt", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical prompt" })
const prompt = Prompt.make({ text: "Historical prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
@@ -443,7 +448,7 @@ describe("SessionV2.prompt", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = new Prompt({ text: "Historical queued prompt" })
const prompt = Prompt.make({ text: "Historical queued prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
@@ -478,7 +483,7 @@ describe("SessionV2.prompt", () => {
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const prompt = new Prompt({ text: "Fix the failing tests" })
const prompt = Prompt.make({ text: "Fix the failing tests" })
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* session
@@ -502,7 +507,7 @@ describe("SessionV2.prompt", () => {
})
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false })
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
@@ -517,7 +522,7 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
@@ -533,7 +538,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run explicitly" }),
prompt: Prompt.make({ text: "Run explicitly" }),
resume: true,
})
@@ -549,7 +554,7 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
@@ -16,7 +16,7 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.
describe("toLLMMessages", () => {
test("omits empty assistant turns", () => {
const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: id(value),
type: "assistant",
agent: "build",
@@ -27,13 +27,13 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
assistant("empty-reasoning", [
new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }),
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
]),
assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
assistant("reasoning", [
new SessionMessage.AssistantReasoning({
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning",
text: "",
@@ -48,43 +48,43 @@ describe("toLLMMessages", () => {
})
test("maps every top-level V2 Session message type", () => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
[
new SessionMessage.AgentSwitched({
SessionMessage.AgentSwitched.make({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
new SessionMessage.ModelSwitched({
SessionMessage.ModelSwitched.make({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created },
}),
new SessionMessage.System({
SessionMessage.System.make({
id: id("system"),
type: "system",
text: "Updated context\n\nOther context",
time: { created },
}),
new SessionMessage.User({
SessionMessage.User.make({
id: id("user"),
type: "user",
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
agents: [AgentAttachment.make({ name: "build" })],
time: { created },
}),
new SessionMessage.Synthetic({
SessionMessage.Synthetic.make({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
new SessionMessage.Shell({
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
callID: "shell-1",
@@ -92,7 +92,7 @@ describe("toLLMMessages", () => {
output: "/project",
time: { created, completed: created },
}),
new SessionMessage.Compaction({
SessionMessage.Compaction.make({
id: id("compaction"),
type: "compaction",
reason: "auto",
@@ -142,31 +142,31 @@ Recent work
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: id("assistant"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
new SessionMessage.AssistantReasoning({
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "pending",
name: "read",
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "running",
name: "read",
state: new SessionMessage.ToolStateRunning({
state: SessionMessage.ToolStateRunning.make({
status: "running",
input: { path: "README.md" },
content: [],
@@ -174,11 +174,11 @@ Recent work
}),
time: { created },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "completed",
name: "read",
state: new SessionMessage.ToolStateCompleted({
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
content: [
@@ -194,7 +194,7 @@ Recent work
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted",
name: "web_search",
@@ -203,7 +203,7 @@ Recent work
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: new SessionMessage.ToolStateCompleted({
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
content: [{ type: "text", text: "Found it" }],
@@ -211,12 +211,12 @@ Recent work
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: new SessionMessage.ToolStateError({
state: SessionMessage.ToolStateError.make({
status: "error",
input: { path: "README.md" },
content: [],
@@ -299,13 +299,13 @@ Recent work
test("restores OpenAI encrypted reasoning metadata", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-openai",
text: "Think",
@@ -330,19 +330,19 @@ Recent work
test("drops provider-native continuation metadata after a model switch", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
SessionMessage.Assistant.make({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
new SessionMessage.AssistantReasoning({
SessionMessage.AssistantReasoning.make({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "hosted-old-model",
name: "web_search",
@@ -351,7 +351,7 @@ Recent work
metadata: { openai: { itemId: "hosted-old-model" } },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: new SessionMessage.ToolStateCompleted({
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { query: "Effect" },
content: [],
@@ -360,7 +360,7 @@ Recent work
}),
time: { created, completed: created },
}),
new SessionMessage.AssistantTool({
SessionMessage.AssistantTool.make({
type: "tool",
id: "local-old-model",
name: "read",
@@ -369,7 +369,7 @@ Recent work
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: new SessionMessage.ToolStateCompleted({
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
content: [],
@@ -154,7 +154,7 @@ describe("SessionRunnerLLM recorded", () => {
const session = yield* SessionV2.Service
const prompt = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Say hello in one short sentence." }),
prompt: Prompt.make({ text: "Say hello in one short sentence." }),
resume: false,
})
+110 -111
View File
@@ -26,7 +26,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
@@ -351,7 +350,7 @@ const setupOverflowRecovery = Effect.gen(function* () {
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
prompt: Prompt.make({ text: "Earlier question ".repeat(700) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -476,7 +475,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
const chunks = Array.from({ length: 32 }, (_, index) => `${index},`)
const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks)
const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
const events = yield* EventV2.Service
const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
@@ -507,7 +506,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
const prompt = `Fail after ${kind}`
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
const failure = providerUnavailable()
yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
@@ -529,7 +528,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
const prompt = `Interrupt after ${kind}`
const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"])
const streamed = yield* Deferred.make<void>()
yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false })
responseStream = Stream.concat(
Stream.fromIterable(fixture.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
@@ -573,7 +572,7 @@ describe("SessionRunnerLLM", () => {
}),
}),
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false })
responses = [
[
LLMEvent.stepStart({ index: 0 }),
@@ -621,7 +620,7 @@ describe("SessionRunnerLLM", () => {
streamStarted = undefined
response = []
const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
expect(requests).toHaveLength(1)
expect(yield* session.messages({ sessionID })).toMatchObject([
@@ -634,8 +633,8 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
requests.length = 0
responses = undefined
@@ -662,7 +661,7 @@ describe("SessionRunnerLLM", () => {
const { db } = yield* Database.Service
const messageID = SessionMessage.ID.create()
systemUnavailable = true
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
@@ -680,7 +679,7 @@ describe("SessionRunnerLLM", () => {
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
@@ -693,7 +692,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
@@ -711,7 +710,7 @@ describe("SessionRunnerLLM", () => {
.get(),
).toBeUndefined()
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
@@ -725,7 +724,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* db
@@ -734,7 +733,7 @@ describe("SessionRunnerLLM", () => {
.where(eq(SessionContextEpochTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
@@ -749,13 +748,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -790,7 +789,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-build", ["Done"]).completeEvents
@@ -816,7 +815,7 @@ describe("SessionRunnerLLM", () => {
editor.default(AgentV2.ID.make("reviewer"))
})
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents
@@ -845,7 +844,7 @@ describe("SessionRunnerLLM", () => {
.run()
.pipe(Effect.orDie)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents
@@ -862,7 +861,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -874,7 +873,7 @@ describe("SessionRunnerLLM", () => {
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -905,7 +904,7 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -935,7 +934,7 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -949,13 +948,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemRemoved = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
@@ -971,13 +970,13 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
@@ -986,7 +985,7 @@ describe("SessionRunnerLLM", () => {
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1006,7 +1005,7 @@ describe("SessionRunnerLLM", () => {
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
}),
)
@@ -1016,7 +1015,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -1028,11 +1027,11 @@ describe("SessionRunnerLLM", () => {
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
systemUnavailable = false
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1048,7 +1047,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -1069,7 +1068,7 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1077,7 +1076,7 @@ describe("SessionRunnerLLM", () => {
["Replacement context"],
])
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
}),
)
@@ -1089,7 +1088,7 @@ describe("SessionRunnerLLM", () => {
response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Earlier question ".repeat(180) }),
prompt: Prompt.make({ text: "Earlier question ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1102,7 +1101,7 @@ describe("SessionRunnerLLM", () => {
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Recent exact request ".repeat(180) }),
prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1128,7 +1127,7 @@ describe("SessionRunnerLLM", () => {
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Newest exact request ".repeat(180) }),
prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1156,7 +1155,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1186,7 +1185,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
overflow(),
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1214,7 +1213,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1232,7 +1231,7 @@ describe("SessionRunnerLLM", () => {
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
@@ -1255,7 +1254,7 @@ describe("SessionRunnerLLM", () => {
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
streamGate = summaryGate
@@ -1275,13 +1274,13 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
@@ -1299,7 +1298,7 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
@@ -1311,7 +1310,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false })
requests.length = 0
responses = undefined
@@ -1409,7 +1408,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
requests.length = 0
authorizations.length = 0
@@ -1468,7 +1467,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
requests.length = 0
responses = [
@@ -1512,7 +1511,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
requests.length = 0
response = [
@@ -1550,7 +1549,7 @@ describe("SessionRunnerLLM", () => {
},
])
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
response = []
yield* session.resume(sessionID)
@@ -1569,7 +1568,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false })
requests.length = 0
response = [
@@ -1594,7 +1593,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
response = []
yield* session.resume(sessionID)
@@ -1624,7 +1623,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false })
requests.length = 0
executions.length = 0
@@ -1685,7 +1684,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false })
requests.length = 0
executions.length = 0
@@ -1773,7 +1772,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false })
requests.length = 0
responses = undefined
@@ -1812,7 +1811,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -1832,7 +1831,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -1855,7 +1854,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -1883,7 +1882,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Wait until continuation ends" }),
prompt: Prompt.make({ text: "Wait until continuation ends" }),
delivery: "queue",
})
yield* Deferred.succeed(streamGate, undefined)
@@ -1903,7 +1902,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
@@ -1921,7 +1920,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run after interrupt" }),
prompt: Prompt.make({ text: "Run after interrupt" }),
delivery: "queue",
})
yield* session.interrupt(sessionID)
@@ -1946,7 +1945,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
@@ -1964,7 +1963,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Steer after interrupt" }),
prompt: Prompt.make({ text: "Steer after interrupt" }),
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
@@ -1988,7 +1987,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2013,8 +2012,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2031,10 +2030,10 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false })
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Queue for later" }),
prompt: Prompt.make({ text: "Queue for later" }),
delivery: "queue",
resume: false,
})
@@ -2065,7 +2064,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2096,13 +2095,13 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" })
streamGate = secondGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer before next queued input" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer before next queued input" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) })
yield* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2130,7 +2129,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2150,8 +2149,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2170,7 +2169,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
requests.length = 0
responses = undefined
@@ -2181,7 +2180,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) })
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
@@ -2200,7 +2199,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
@@ -2262,7 +2261,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
@@ -2322,7 +2321,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Recover interrupted tool input" }),
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
@@ -2360,7 +2359,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Wait in queue" }),
prompt: Prompt.make({ text: "Wait in queue" }),
delivery: "queue",
resume: false,
})
@@ -2382,7 +2381,7 @@ describe("SessionRunnerLLM", () => {
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
fail = false
@@ -2410,7 +2409,7 @@ describe("SessionRunnerLLM", () => {
)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run committed promotion" }),
prompt: Prompt.make({ text: "Run committed promotion" }),
resume: false,
})
@@ -2427,8 +2426,8 @@ describe("SessionRunnerLLM", () => {
yield* setup
yield* insertSession(otherSessionID)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run first" }), resume: false })
yield* session.prompt({ sessionID: otherSessionID, prompt: new Prompt({ text: "Run second" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false })
yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false })
requests.length = 0
responses = undefined
@@ -2470,12 +2469,12 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID: externalSessionID,
prompt: new Prompt({ text: "Run external session" }),
prompt: Prompt.make({ text: "Run external session" }),
resume: false,
})
yield* session.prompt({
sessionID: otherExternalSessionID,
prompt: new Prompt({ text: "Run other external session" }),
prompt: Prompt.make({ text: "Run other external session" }),
resume: false,
})
@@ -2494,7 +2493,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false })
requests.length = 0
responses = undefined
@@ -2525,7 +2524,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false })
requests.length = 0
responses = [
@@ -2571,7 +2570,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false })
requests.length = 0
responses = [
@@ -2620,7 +2619,7 @@ describe("SessionRunnerLLM", () => {
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}),
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
requests.length = 0
responses = [
@@ -2665,7 +2664,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false })
const failure = providerUnavailable()
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
@@ -2699,7 +2698,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
executions.length = 0
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
@@ -2749,7 +2748,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false })
requests.length = 0
response = []
streamGate = yield* Deferred.make<void>()
@@ -2772,7 +2771,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
executions.length = 0
toolExecutionGate = yield* Deferred.make<void>()
response = [
@@ -2815,7 +2814,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false })
requests.length = 0
executions.length = 0
@@ -2863,7 +2862,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start work" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false })
requests.length = 0
executions.length = 0
@@ -2891,7 +2890,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
streamGate = undefined
@@ -2909,7 +2908,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false })
requests.length = 0
responses = undefined
@@ -2931,7 +2930,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false })
requests.length = 0
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
@@ -2950,7 +2949,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false })
requests.length = 0
response = [
@@ -2979,7 +2978,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
const failure = providerUnavailable()
responseStream = Stream.fail(failure)
@@ -2998,7 +2997,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Do not continue failed provider" }),
prompt: Prompt.make({ text: "Do not continue failed provider" }),
resume: false,
})
@@ -3021,7 +3020,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false })
requests.length = 0
response = [
@@ -3052,7 +3051,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false })
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({
@@ -3079,7 +3078,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Fail hosted tool on raw failure" }),
prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
resume: false,
})
const failure = providerUnavailable()
@@ -3114,7 +3113,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
responses = undefined
streamGate = undefined
@@ -3177,7 +3176,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false })
responses = undefined
streamGate = undefined
+42
View File
@@ -0,0 +1,42 @@
# @opencode-ai/httpapi-codegen
Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts.
The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures.
## Settled rules
- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`.
- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`.
- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source.
- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`.
- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation.
- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`.
- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically.
- Flatten path, query, header, and payload fields into one input object.
- Reject duplicate field names across input channels.
- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required.
- Unwrap exact `{ data: A }` success envelopes.
- Map no-content success to `void`.
- Preserve other single success values.
- Reject ambiguous multiple-success contracts.
- Expose streaming success as `Stream`, not `Effect<Stream>`.
- Reject schemas whose wire/domain transformation cannot be generated exactly.
- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`.
- Commit generated source for review; CI regenerates and fails when the worktree changes.
- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator.
## Boundary
This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities.
Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy.
The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
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.
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.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/httpapi-codegen",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test --timeout 5000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
File diff suppressed because one or more lines are too long
+28
View File
@@ -0,0 +1,28 @@
import { test } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type { Scope } from "effect/Scope"
import { TestClock, TestConsole } from "effect/testing"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect }
+45
View File
@@ -0,0 +1,45 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
export class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {
message: Schema.String,
}) {}
export const Api = HttpApi.make("fixture")
.add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String }))
.add(
HttpApiEndpoint.get("list", "/session", {
query: { archived: Schema.optional(Schema.Boolean) },
success: Schema.Array(Schema.String),
}),
)
.add(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
error: Missing.pipe(HttpApiSchema.status(404)),
}),
)
.add(
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
params: { sessionID: Schema.String },
success: HttpApiSchema.NoContent,
}),
),
)
.add(
HttpApiGroup.make("event").add(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe(
HttpApiSchema.status(202),
),
}),
),
)
.add(
HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
),
)
@@ -0,0 +1,897 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
import { format } from "prettier"
import {
compile as compileContract,
emitEffect,
emitEffectImported,
emitPromise,
generate,
GenerationError,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
function api(endpoint: HttpApiEndpoint.Any) {
return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
}
function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
return emitEffect(compileContract(source))
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const promise = emitPromise(contract)
const effect = emitEffect(contract)
expect(promise.operations).toEqual(effect.operations)
expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("emits an Effect client against an imported authoritative API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", api: "Api" },
)
expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'import { Api } from "@example/api"',
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
"HttpApiClient.ForApi<typeof Api>",
)
})
test("projects imported endpoint constants into a generated API", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
{ module: "@example/api", endpoints: { "session.get": "SessionGet" } },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGet } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
})
test("imports an authoritative group without reconstructing it", () => {
const output = emitEffectImported(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
),
{ module: "@example/api", group: "SessionGroup" },
)
const client = output.files.find((file) => file.path === "client.ts")?.content
expect(client).toContain('import { SessionGroup } from "@example/api"')
expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
expect(client).not.toContain("HttpApiGroup")
})
test("separates hosted and consumer group names", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session").add(
HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
),
)
const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
expect(contract.groups[0]?.identifier).toBe("sessions")
expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
})
test("rejects consumer group name collisions", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
.add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
"Client group name collision: same",
)
})
test("uses the unqualified endpoint name for the public client", () => {
const contract = compileContract(
api(
HttpApiEndpoint.get("session.get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.String,
}),
),
)
const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
const effect = emitEffectImported(contract, {
module: "@example/api",
endpoints: { "session.session.get": "SessionGet" },
}).files.find((file) => file.path === "client.ts")?.content
expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
expect(effect).toContain('raw["session.get"]')
})
test("preserves optional keys in Promise error types", () => {
class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
"OptionalError",
{ message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
{ httpApiStatus: 400 },
) {}
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'readonly "message": string; readonly "detail"?: string | undefined',
)
})
test("erases brands from Promise wire types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('readonly "sessionID": string')
expect(types).not.toContain("Brand")
})
test("inlines non-recursive references in Promise wire types", () => {
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Struct({ data: Referenced }),
}),
),
),
)
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
)
})
test("emits Effect Json schemas as standalone Promise types", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.Json,
}),
),
),
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain("export type JsonValue =")
expect(types).toContain("{ readonly [key: string]: JsonValue }")
expect(types).not.toContain("Schema.Json")
})
test("emits an optional Promise input when every field is optional", () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.Number) },
success: Schema.Array(Schema.String),
}),
),
),
)
expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
'"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
)
})
test("rejects Promise transports that are not implemented", () => {
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("text", "/text", {
success: Schema.String.pipe(HttpApiSchema.asText()),
}),
),
),
),
).toThrow("Unsupported Promise success encoding: session.text")
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("events", "/events", {
success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
}),
),
),
),
).toThrow("Unsupported Promise stream: session.events")
})
test("executes an emitted Promise GET through fetch", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("maps an emitted no-content response to undefined", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
params: { sessionID: Schema.String },
success: HttpApiSchema.NoContent,
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.optional(Schema.Boolean) },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects with declared tagged errors and exports a type guard", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("subscribe", "/event", {
query: { after: Schema.optional(Schema.Number) },
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("preserves public group and endpoint identifiers exactly", () => {
const output = compile(
HttpApi.make("test").add(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
),
)
expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
})
test("emits one client module per HttpApi group", () => {
const source = HttpApi.make("test")
.add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
const output = compile(source)
expect(output.files.map((file) => file.path)).toEqual([
"session.ts",
"tool.ts",
"client-error.ts",
"client.ts",
"index.ts",
])
})
test("emits syntactically valid TypeScript modules", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
const transpiler = new Bun.Transpiler({ loader: "ts" })
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.effect("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
)
expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
)
yield* Effect.forEach(output.files, (file) =>
Effect.tryPromise(() =>
Promise.all([
Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
]),
).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
)
}),
)
test("flattens transport input channels into one domain input", () => {
const output = compile(
api(
HttpApiEndpoint.post("prompt", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { resume: Schema.String },
headers: { traceID: Schema.String },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.input).toEqual([
{ name: "sessionID", source: "params" },
{ name: "resume", source: "query" },
{ name: "traceID", source: "headers" },
{ name: "prompt", source: "payload" },
])
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'params: { "sessionID": input["sessionID"] }',
)
})
test("uses no argument when an operation has no input fields", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.inputMode).toBe("none")
})
test("uses an optional object when every input field is optional", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { limit: Schema.optional(Schema.String) },
success: Schema.Array(Schema.String),
}),
),
)
expect(output.operations[0]?.inputMode).toBe("optional")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
})
test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
const output = compile(
api(
HttpApiEndpoint.get("list", "/session", {
query: { archived: Schema.optional(Schema.Boolean) },
success: Schema.String,
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
})
test("uses a required object when any input field is required", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
query: { includeArchived: Schema.optional(Schema.String) },
success: Schema.String,
}),
),
)
expect(output.operations[0]?.inputMode).toBe("required")
})
test("rejects colliding input names across transport channels", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session/:id", {
params: { id: Schema.String },
payload: Schema.Struct({ id: Schema.String }),
success: Schema.Void,
}),
),
),
).toThrow("Input field collision: id")
})
test("rejects multiple payload alternatives until selection semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.post("prompt", "/session", {
payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
success: Schema.String,
}),
),
),
).toThrow("Multiple payload schemas: session.prompt")
})
test("unwraps an exact data success envelope", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.String }),
}),
),
)
expect(output.operations[0]?.success).toBe("value")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"Effect.map((value) => value.data)",
)
})
test("maps no-content success to void", () => {
const output = compile(
api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
)
expect(output.operations[0]?.success).toBe("void")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
})
test("preserves non-default empty response statuses", () => {
const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
})
test("returns a non-envelope success unchanged", () => {
const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
expect(output.operations[0]?.success).toBe("value")
})
test("rejects multiple success shapes until their public semantics are explicit", () => {
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: [Schema.String, Schema.Number],
}),
),
),
).toThrow("Multiple success schemas: session.get")
})
test("models an SSE success as a direct stream", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
}),
),
)
expect(output.operations[0]?.success).toBe("stream")
})
test("preserves annotated stream response statuses", () => {
const output = compile(
api(
HttpApiEndpoint.get("subscribe", "/event", {
success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
".pipe(HttpApiSchema.status(202))",
)
})
test("rejects schemas whose semantics cannot be emitted exactly", () => {
const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "yes"),
encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
}),
)
expect(() =>
compile(
api(
HttpApiEndpoint.get("get", "/session", {
query: { archived: QueryBoolean },
success: Schema.String,
}),
),
),
).toThrow("Effect schema requires authoritative import: session.get")
})
test("rejects custom validation checks without portable metadata", () => {
const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
"Unportable schema: session.get.success",
)
})
test("rejects spoofed and aborted validation checks", () => {
const Spoofed = Schema.Number.check(
Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
)
const Aborted = Schema.Number.check(Schema.isFinite().abort())
expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
"Unportable schema: session.spoofed.success",
)
expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
"Unportable schema: session.aborted.success",
)
})
test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
const JsonNumber = Schema.toCodecJson(Schema.Number)
const link = JsonNumber.ast.encoding?.[0]
if (link === undefined) throw new Error("Expected JSON number encoding")
// This helper is present at runtime but omitted from the public declaration surface.
const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
const ast: unknown = replaceEncoding(JsonNumber.ast, [
new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
])
if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
const Altered = Schema.make(ast)
expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
"Effect schema requires authoritative import: session.get",
)
})
test("rejects lexical generation and annotation values", () => {
const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
generation: { runtime: "LocalOnly", Type: "string" },
})
const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
custom: () => "local",
})
expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
"Unportable schema: session.generated.success",
)
expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
"Unportable schema: session.annotated.success",
)
})
test("preserves errors from server-only middleware", () => {
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
error: Unauthorized,
}) {}
const output = compile(
api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
)
expect(output.operations[0]).toBeDefined()
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
)
})
test("preserves tagged error response statuses", () => {
class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
error: Missing.pipe(HttpApiSchema.status(404)),
}),
),
)
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
)
})
test("supports every HttpApi method through the generic constructor", () => {
const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
})
test("uses safe unique module paths without changing public group identifiers", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
.add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
})
test("reserves support module names case-insensitively", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
.add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
})
test("keeps searching when a reserved-name fallback is also occupied", () => {
const output = compile(
HttpApi.make("test")
.add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
)
expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
})
test("rejects collisions in the flattened client namespace", () => {
expect(() =>
compile(
HttpApi.make("test")
.add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
.add(
HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
),
),
),
).toThrow("Client name collision: status")
})
test("emits a usable raw type for top-level groups", () => {
const output = compile(
HttpApi.make("test").add(
HttpApiGroup.make("health", { topLevel: true }).add(
HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
),
),
)
expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
})
it.effect("reports compiler failures in the generate Effect", () =>
Effect.gen(function* () {
const error = yield* generate(
api(
HttpApiEndpoint.get("get", "/url", {
success: Schema.declare((input): input is URL => input instanceof URL),
}),
),
{
directory: "/generated",
},
).pipe(Effect.flip)
expect(error).toBeInstanceOf(GenerationError)
if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
}).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
)
test("rejects required client middleware without an adapter", () => {
class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
requiredForClient: true,
}) {}
expect(() =>
compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
).toThrow("Client middleware requires adapter: SignedRequest")
})
test("maps transport and decode failures to one stable client error", () => {
const output = compile(
api(
HttpApiEndpoint.get("get", "/session", {
success: Schema.String,
}),
),
)
expect(output.operations[0]?.errors).toContain("ClientError")
expect(output.operations[0]?.errors).not.toContain("HttpClientError")
expect(output.operations[0]?.errors).not.toContain("SchemaError")
expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
"new ClientError({ cause: error })",
)
})
})
@@ -0,0 +1,28 @@
import { Effect, Stream } from "effect"
import { HttpClient } from "effect/unstable/http"
import { ClientError, OpenCode } from "./generated"
import { Missing } from "./fixture"
export const program = OpenCode.make().pipe(
Effect.map((client) => {
const health = client.session.health()
const list = client.session.list()
const filtered = client.session.list({ archived: true })
const get = client.session.get({ sessionID: "session" })
const interrupt = client.session.interrupt({ sessionID: "session" })
const status = client.status()
const subscribe = client.event.subscribe()
const _health: Effect.Effect<string, ClientError> = health
const _list: Effect.Effect<ReadonlyArray<string>, ClientError> = list
const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
const _get: Effect.Effect<string, Missing | ClientError> = get
const _interrupt: Effect.Effect<void, ClientError> = interrupt
const _status: Effect.Effect<string, ClientError> = status
const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
}),
)
const _requiresHttpClient: Effect.Effect<unknown, never, HttpClient.HttpClient> = program
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -0,0 +1,16 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect } from "effect"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { adaptGroup0, Group0 } from "./session"
import { adaptGroup1, Group1 } from "./event"
import { adaptGroup2, Group2 } from "./system"
const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
session: adaptGroup0(raw["session"]),
event: adaptGroup1(raw["event"]),
...adaptGroup2({ status: raw["status"] }),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
@@ -0,0 +1,39 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema, Stream } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
const Endpoint0SuccessError = Schema.Never
export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
HttpApiEndpoint.make("GET")("subscribe", "/event", {
success: HttpApiSchema.StreamSse({
data: Endpoint0SuccessData,
error: Endpoint0SuccessError,
contentType: "text/event-stream",
}).pipe(HttpApiSchema.status(202)),
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group1, "event", never, never>
const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () =>
Stream.unwrap(
raw["subscribe"]({}).pipe(
Effect.mapError(mapEndpoint0Error),
Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
),
)
export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })
@@ -0,0 +1,2 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -0,0 +1,96 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0Success = Schema.String
const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
const Endpoint1Success = Schema.Array(Schema.String)
const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint2Success = Schema.Struct({ data: Schema.String })
class Endpoint2Error0Class extends Schema.TaggedErrorClass<Endpoint2Error0Class>("Missing")("Missing", {
message: Schema.String,
}) {}
const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
export const Group0 = HttpApiGroup.make("session", { topLevel: false })
.add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
.add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
.add(
HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
params: Endpoint2Params,
success: Endpoint2Success,
error: Endpoint2Error0,
}),
)
.add(
HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
params: Endpoint3Params,
success: Endpoint3Success,
}),
)
type RawGroup = HttpApiClient.Client.Group<typeof Group0, "session", never, never>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
const Endpoint1DeclaredError = Schema.Never
const mapEndpoint1Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint1DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
const mapEndpoint2Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint2DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapEndpoint2Error),
Effect.map((value) => value.data),
)
type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
const Endpoint3DeclaredError = Schema.Never
const mapEndpoint3Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint3DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
export const adaptGroup0 = (raw: RawGroup) => ({
health: Endpoint0(raw),
list: Endpoint1(raw),
get: Endpoint2(raw),
interrupt: Endpoint3(raw),
})
@@ -0,0 +1,25 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import { ClientError } from "./client-error"
const Endpoint0Success = Schema.String
export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
)
type RawGroup = HttpApiClient.Client<typeof Group2>
const Endpoint0DeclaredError = Schema.Never
const mapEndpoint0Error = (error: unknown) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: Schema.is(Endpoint0DeclaredError)(error)
? error
: new ClientError({ cause: error })
const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect } from "bun:test"
import { Effect, FileSystem, Option } from "effect"
import { write, type Output } from "../src"
import { it } from "./effect"
describe("HttpApiCodegen.write", () => {
it.effect("writes compiled files beneath the output directory", () => {
const writes: Array<{ readonly path: string; readonly content: string }> = []
const output: Output = {
operations: [],
files: [{ path: "session.ts", content: "export const session = {}" }],
}
return Effect.gen(function* () {
yield* write(output, "/generated")
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: () => Effect.succeed(false),
makeDirectory: () => Effect.void,
writeFileString: (path, content) => {
writes.push({ path, content })
return Effect.void
},
}),
),
)
})
it.effect("removes only stale files owned by the previous manifest", () => {
const removed: Array<string> = []
return write(
{
operations: [],
files: [{ path: "session.ts", content: "" }],
},
"/generated",
).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
makeDirectory: () => Effect.void,
readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
remove: (path) => {
removed.push(path)
return Effect.void
},
writeFileString: () => Effect.void,
}),
),
Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
)
})
it.effect("rejects unsafe and duplicate output paths before writing", () => {
const writes: Array<string> = []
return Effect.gen(function* () {
const error = yield* write(
{
operations: [],
files: [
{ path: "../outside.ts", content: "" },
{ path: "client.ts", content: "" },
{ path: "CLIENT.ts", content: "" },
],
},
"/generated",
).pipe(Effect.flip)
expect(error._tag).toBe("GenerationError")
expect(writes).toEqual([])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
writeFileString: (path) => {
writes.push(path)
return Effect.void
},
}),
),
)
})
it.effect("rejects case-insensitive duplicate output paths", () => {
const writes: Array<string> = []
return Effect.gen(function* () {
const error = yield* write(
{
operations: [],
files: [
{ path: "client.ts", content: "" },
{ path: "CLIENT.ts", content: "" },
],
},
"/generated",
).pipe(Effect.flip)
expect(error._tag).toBe("GenerationError")
expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
expect(writes).toEqual([])
}).pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
writeFileString: (path) => {
writes.push(path)
return Effect.void
},
}),
),
)
})
it.effect("reserves the private manifest path", () =>
write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
),
)
it.effect("rejects existing symbolic-link output targets", () =>
write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({
exists: (path) => Effect.succeed(path.endsWith("session.ts")),
makeDirectory: () => Effect.void,
stat: () =>
Effect.succeed({
type: "SymbolicLink",
mtime: Option.none(),
atime: Option.none(),
birthtime: Option.none(),
dev: 0,
ino: Option.none(),
mode: 0,
nlink: Option.none(),
uid: Option.none(),
gid: Option.none(),
rdev: Option.none(),
size: FileSystem.Size(0),
blksize: Option.none(),
blocks: Option.none(),
}),
}),
),
),
)
})
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
@@ -54,6 +54,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
import { EventV2 } from "@opencode-ai/core/event"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Npm } from "@opencode-ai/core/npm"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -280,6 +281,7 @@ export function createRoutes(
HttpServer.layerServices,
]),
Layer.provide(LayerNode.buildLayer(app)),
Layer.provide(ApplicationTools.layer),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(Observability.layer),
)
+5 -5
View File
@@ -1086,12 +1086,12 @@ export const layer = Layer.effect(
}
if (part.type === "file") {
result.files.push(
new FileAttachment({
FileAttachment.make({
uri: part.url,
mime: part.mime,
name: part.filename,
source: part.source
? new Source({
? Source.make({
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
@@ -1102,10 +1102,10 @@ export const layer = Layer.effect(
}
if (part.type === "agent") {
result.agents.push(
new AgentAttachment({
AgentAttachment.make({
name: part.name,
source: part.source
? new Source({
? Source.make({
start: part.source.start,
end: part.source.end,
text: part.source.value,
@@ -1130,7 +1130,7 @@ export const layer = Layer.effect(
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(info.time.created),
delivery: "steer",
prompt: new Prompt({
prompt: Prompt.make({
text: nextPrompt.text.join("\n"),
files: nextPrompt.files,
agents: nextPrompt.agents,
@@ -16,6 +16,7 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly operationId?: string
readonly parameters?: ReadonlyArray<{
readonly name: string
readonly in: string
@@ -68,6 +69,18 @@ function isBuiltInEndpointError(name: string) {
}
describe("PublicApi OpenAPI v2 errors", () => {
test("keeps current and v2 session groups distinct", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
expect(spec.paths["/session"]?.get?.operationId).toBe("session.list")
expect(spec.paths["/session/{sessionID}"]?.get?.operationId).toBe("session.get")
expect(spec.paths["/api/session"]?.get?.operationId).toBe("v2.session.list")
expect(spec.paths["/api/session/{sessionID}"]?.get?.operationId).toBe("v2.session.get")
expect(responseRef(spec.paths["/api/session"]?.get?.responses?.["200"])).toBe(
"#/components/schemas/SessionsResponse",
)
})
test("documents nested legacy global sync events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const schema = spec.components.schemas.SyncEventSessionCreated
@@ -23,7 +23,6 @@ import * as HttpSessionError from "../../src/server/routes/instance/httpapi/hand
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@opencode-ai/core/database/database"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -129,7 +128,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
Effect.gen(function* () {
const message = new SessionMessage.Assistant({
const message = SessionMessage.Assistant.make({
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/protocol",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
"./errors": "./src/errors.ts",
"./session": "./src/session.ts",
"./session-cursor": "./src/session-cursor.ts",
"./middleware/session-location": "./src/middleware/session-location.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { Schema } from "effect"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
{
message: Schema.String,
kind: Schema.optional(Schema.String),
field: Schema.optional(Schema.String),
},
{ httpApiStatus: 400 },
) {}
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
"ConflictError",
{ message: Schema.String, resource: Schema.optional(Schema.String) },
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{ message: Schema.String, service: Schema.optional(Schema.String) },
{ httpApiStatus: 503 },
) {}
export class UnknownError extends Schema.TaggedErrorClass<UnknownError>()(
"UnknownError",
{ message: Schema.String, ref: Schema.optional(Schema.String) },
{ httpApiStatus: 500 },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"ProviderNotFoundError",
{ providerID: Schema.String, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{ sessionID: Schema.String, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
"PermissionNotFoundError",
{ requestID: Schema.String, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
"QuestionNotFoundError",
{ requestID: Schema.String, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
"ForbiddenError",
{ message: Schema.String },
{ httpApiStatus: 403 },
) {}
export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>()(
"PtyNotFoundError",
{ ptyID: Schema.String, message: Schema.String },
{ httpApiStatus: 404 },
) {}
@@ -0,0 +1,7 @@
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "../errors"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
"@opencode/HttpApiSessionLocation",
{ error: [InvalidRequestError, SessionNotFoundError] },
) {}
+30
View File
@@ -0,0 +1,30 @@
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Project } from "@opencode-ai/schema/project"
import { Session } from "@opencode-ai/schema/session"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Effect, Encoding, Schema, Struct } from "effect"
const fields = {
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
}
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((value) => ({ ...Struct.omit(value, ["limit"]), anchor: Session.ListAnchor }))
const input = Schema.Union([
withCursor(Schema.Struct({ ...fields, directory: AbsolutePath })),
withCursor(Schema.Struct({ ...fields, project: Project.ID, subpath: RelativePath.pipe(Schema.optional) })),
withCursor(Schema.Struct(fields)),
])
const json = Schema.fromJsonString(input)
const encode = Schema.encodeSync(json)
const decode = Schema.decodeUnknownEffect(json)
const schema = Schema.String.pipe(Schema.brand("SessionsCursor"))
const make = Schema.decodeUnknownSync(schema)
export const SessionsCursor = Object.assign(schema, {
make: (value: typeof input.Type) => make(Encoding.encodeBase64Url(encode(value))),
parse: (value: string) => Effect.fromResult(Encoding.decodeBase64UrlString(value)).pipe(Effect.flatMap(decode)),
})
export type SessionsCursor = typeof SessionsCursor.Type
+194
View File
@@ -0,0 +1,194 @@
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
ServiceUnavailableError,
SessionNotFoundError,
UnauthorizedError,
UnknownError,
} from "./errors"
import { SessionLocationMiddleware } from "./middleware/session-location"
import { SessionsCursor } from "./session-cursor"
export { SessionsCursor } from "./session-cursor"
export const SessionsQuery = Schema.Struct({
workspace: Workspace.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
search: Schema.String.pipe(Schema.optional),
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(Session.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError, UnauthorizedError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
error: [InvalidRequestError, UnauthorizedError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Session.Info }),
error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ agent: Agent.ID }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchAgent",
summary: "Switch session agent",
description: "Switch the agent used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ model: Model.Ref }),
success: HttpApiSchema.NoContent,
error: [InvalidRequestError, SessionNotFoundError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchModel",
summary: "Switch session model",
description: "Switch the model used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, InvalidRequestError, SessionNotFoundError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
params: { sessionID: Session.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError, UnauthorizedError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
error: [SessionNotFoundError, UnauthorizedError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "sessions", description: "Experimental session routes." }))
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/schema",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@noble/hashes": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+6
View File
@@ -0,0 +1,6 @@
export * as Agent from "./agent"
import { Schema } from "effect"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
+31
View File
@@ -0,0 +1,31 @@
const length = 26
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let lastTimestamp = 0
let counter = 0
export function ascending() {
return create(false)
}
export function descending() {
return create(true)
}
function create(descending: boolean) {
const timestamp = Date.now()
if (timestamp !== lastTimestamp) {
lastTimestamp = timestamp
counter = 0
}
counter++
const current = BigInt(timestamp) * 0x1000n + BigInt(counter)
const value = descending ? ~current : current
const time = Array.from({ length: 6 }, (_, index) =>
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
.toString(16)
.padStart(2, "0"),
).join("")
const bytes = crypto.getRandomValues(new Uint8Array(length - 12))
return time + Array.from(bytes, (byte) => chars[byte % 62]).join("")
}
+11
View File
@@ -0,0 +1,11 @@
export { Agent } from "./agent"
export { Location } from "./location"
export { Model } from "./model"
export { Project } from "./project"
export { Provider } from "./provider"
export { Session } from "./session"
export { SessionInput } from "./session-input"
export { SessionMessage } from "./session-message"
export { Workspace } from "./workspace"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
export * from "./schema"
+11
View File
@@ -0,0 +1,11 @@
export * as Location from "./location"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "./schema"
import { Workspace } from "./workspace"
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Ref = Schema.Struct({
directory: AbsolutePath,
workspaceID: Schema.optional(Workspace.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}).annotate({ identifier: "Location.Ref" })
+17
View File
@@ -0,0 +1,17 @@
export * as Model from "./model"
import { Schema } from "effect"
import { Provider } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export type ID = typeof ID.Type
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export type VariantID = typeof VariantID.Type
export const Ref = Schema.Struct({
id: ID,
providerID: Provider.ID,
variant: VariantID.pipe(Schema.optional),
})
export type Ref = typeof Ref.Type
+10
View File
@@ -0,0 +1,10 @@
export * as Project from "./project"
import { Schema } from "effect"
import { withStatics } from "./schema"
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({ global: schema.make("global") })),
)
export type ID = typeof ID.Type
+30
View File
@@ -0,0 +1,30 @@
import { Schema } from "effect"
export interface Source extends Schema.Schema.Type<typeof Source> {}
export const Source = Schema.Struct({
start: Schema.Finite,
end: Schema.Finite,
text: Schema.String,
}).annotate({ identifier: "Prompt.Source" })
export interface FileAttachment extends Schema.Schema.Type<typeof FileAttachment> {}
export const FileAttachment = Schema.Struct({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "Prompt.FileAttachment" })
export interface AgentAttachment extends Schema.Schema.Type<typeof AgentAttachment> {}
export const AgentAttachment = Schema.Struct({
name: Schema.String,
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "Prompt.AgentAttachment" })
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Prompt = Schema.Struct({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
}).annotate({ identifier: "Prompt" })
+22
View File
@@ -0,0 +1,22 @@
export * as Provider from "./provider"
import { Schema } from "effect"
import { withStatics } from "./schema"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
withStatics((schema) => ({
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ID = typeof ID.Type
+40
View File
@@ -0,0 +1,40 @@
import { DateTime, Option, Schema, SchemaGetter } from "effect"
import { sha256 } from "@noble/hashes/sha2.js"
import { bytesToHex } from "@noble/hashes/utils.js"
export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = typeof RelativePath.Type
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = typeof AbsolutePath.Type
export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
Schema.optionalKey(schema).pipe(
Schema.decodeTo(Schema.optional(schema), {
decode: SchemaGetter.passthrough({ strict: false }),
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
}),
)
export const withStatics =
<S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
(schema: S): S & M =>
Object.assign(schema, methods(schema))
export interface ExternalID {
readonly namespace: string
readonly key: string
}
export const externalID = (prefix: string, input: ExternalID) =>
`${prefix}_${bytesToHex(sha256(new TextEncoder().encode(JSON.stringify([input.namespace, input.key]))))}`
export const DateTimeUtcFromMillis = Schema.Finite.pipe(
Schema.decodeTo(Schema.DateTimeUtc, {
decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
}),
)
+21
View File
@@ -0,0 +1,21 @@
export * as SessionInput from "./session-input"
import { Schema } from "effect"
import { Prompt } from "./prompt"
import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema"
import { Session } from "./session"
import { SessionMessage } from "./session-message"
export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
export interface Admitted extends Schema.Schema.Type<typeof Admitted> {}
export const Admitted = Schema.Struct({
admittedSeq: NonNegativeInt,
id: SessionMessage.ID,
sessionID: Session.ID,
prompt: Prompt,
delivery: Delivery,
timeCreated: DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(Schema.optional),
}).annotate({ identifier: "SessionInput.Admitted" })
+218
View File
@@ -0,0 +1,218 @@
export * as SessionMessage from "./session-message"
import { Schema } from "effect"
import { Model } from "./model"
import { FileAttachment, Prompt } from "./prompt"
import { DateTimeUtcFromMillis, withStatics } from "./schema"
import { Session } from "./session"
import { ascending } from "./identifier"
export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe(
Schema.brand("Session.Message.ID"),
withStatics((schema) => ({ create: () => schema.make("msg_" + ascending()) })),
)
export type ID = typeof ID.Type
const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))
const ToolContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }).annotate({
identifier: "Tool.TextContent",
}),
Schema.Struct({
type: Schema.Literal("file"),
uri: Schema.String,
mime: Schema.String,
name: Schema.optional(Schema.String),
}).annotate({ identifier: "Tool.FileContent" }),
]).pipe(Schema.toTaggedUnion("type"))
const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({ identifier: "Session.Error.Unknown" })
const Base = {
id: ID,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
}
export interface AgentSwitched extends Schema.Schema.Type<typeof AgentSwitched> {}
export const AgentSwitched = Schema.Struct({
...Base,
type: Schema.Literal("agent-switched"),
agent: Schema.String,
}).annotate({ identifier: "Session.Message.AgentSwitched" })
export interface ModelSwitched extends Schema.Schema.Type<typeof ModelSwitched> {}
export const ModelSwitched = Schema.Struct({
...Base,
type: Schema.Literal("model-switched"),
model: Model.Ref,
}).annotate({ identifier: "Session.Message.ModelSwitched" })
export interface User extends Schema.Schema.Type<typeof User> {}
export const User = Schema.Struct({
...Base,
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
type: Schema.Literal("user"),
time: Schema.Struct({ created: DateTimeUtcFromMillis }),
}).annotate({ identifier: "Session.Message.User" })
export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
export const Synthetic = Schema.Struct({
...Base,
sessionID: Session.ID,
text: Schema.String,
type: Schema.Literal("synthetic"),
}).annotate({ identifier: "Session.Message.Synthetic" })
export interface System extends Schema.Schema.Type<typeof System> {}
export const System = Schema.Struct({
...Base,
type: Schema.Literal("system"),
text: Schema.String,
}).annotate({ identifier: "Session.Message.System" })
export interface Shell extends Schema.Schema.Type<typeof Shell> {}
export const Shell = Schema.Struct({
...Base,
type: Schema.Literal("shell"),
callID: Schema.String,
command: Schema.String,
output: Schema.String,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}).annotate({ identifier: "Session.Message.Shell" })
export interface ToolStatePending extends Schema.Schema.Type<typeof ToolStatePending> {}
export const ToolStatePending = Schema.Struct({
status: Schema.Literal("pending"),
input: Schema.String,
}).annotate({ identifier: "Session.Message.ToolState.Pending" })
export interface ToolStateRunning extends Schema.Schema.Type<typeof ToolStateRunning> {}
export const ToolStateRunning = Schema.Struct({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: Schema.Record(Schema.String, Schema.Any),
content: ToolContent.pipe(Schema.Array),
}).annotate({ identifier: "Session.Message.ToolState.Running" })
export interface ToolStateCompleted extends Schema.Schema.Type<typeof ToolStateCompleted> {}
export const ToolStateCompleted = Schema.Struct({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolContent.pipe(Schema.Array),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
structured: Schema.Record(Schema.String, Schema.Any),
result: Schema.Unknown.pipe(Schema.optional),
}).annotate({ identifier: "Session.Message.ToolState.Completed" })
export interface ToolStateError extends Schema.Schema.Type<typeof ToolStateError> {}
export const ToolStateError = Schema.Struct({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
error: UnknownError,
result: Schema.Unknown.pipe(Schema.optional),
}).annotate({ identifier: "Session.Message.ToolState.Error" })
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
Schema.toTaggedUnion("status"),
)
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export interface AssistantTool extends Schema.Schema.Type<typeof AssistantTool> {}
export const AssistantTool = Schema.Struct({
type: Schema.Literal("tool"),
id: Schema.String,
name: Schema.String,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
resultMetadata: ProviderMetadata.pipe(Schema.optional),
}).pipe(Schema.optional),
state: ToolState,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
ran: DateTimeUtcFromMillis.pipe(Schema.optional),
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
pruned: DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}).annotate({ identifier: "Session.Message.Assistant.Tool" })
export interface AssistantText extends Schema.Schema.Type<typeof AssistantText> {}
export const AssistantText = Schema.Struct({
type: Schema.Literal("text"),
id: Schema.String,
text: Schema.String,
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
export const AssistantReasoning = Schema.Struct({
type: Schema.Literal("reasoning"),
id: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
}).annotate({ identifier: "Session.Message.Assistant.Reasoning" })
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Schema.toTaggedUnion("type"),
)
export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool
export interface Assistant extends Schema.Schema.Type<typeof Assistant> {}
export const Assistant = Schema.Struct({
...Base,
type: Schema.Literal("assistant"),
agent: Schema.String,
model: Model.Ref,
content: AssistantContent.pipe(Schema.Array),
snapshot: Schema.Struct({
start: Schema.String.pipe(Schema.optional),
end: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
finish: Schema.String.pipe(Schema.optional),
cost: Schema.Finite.pipe(Schema.optional),
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }),
}).pipe(Schema.optional),
error: UnknownError.pipe(Schema.optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(Schema.optional),
}),
}).annotate({ identifier: "Session.Message.Assistant" })
export interface Compaction extends Schema.Schema.Type<typeof Compaction> {}
export const Compaction = Schema.Struct({
type: Schema.Literal("compaction"),
reason: Schema.Literals(["auto", "manual"]),
summary: Schema.String,
recent: Schema.String,
...Base,
}).annotate({ identifier: "Session.Message.Compaction" })
export const Message = Schema.Union([
AgentSwitched,
ModelSwitched,
User,
Synthetic,
System,
Shell,
Assistant,
Compaction,
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Message" })
export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction
export type Type = Message["type"]
+57
View File
@@ -0,0 +1,57 @@
export * as Session from "./session"
import { Schema } from "effect"
import { Agent } from "./agent"
import { Location } from "./location"
import { Model } from "./model"
import { Project } from "./project"
import { DateTimeUtcFromMillis, externalID, type ExternalID, optionalOmitUndefined, RelativePath } from "./schema"
import { withStatics } from "./schema"
import { descending } from "./identifier"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => {
const create = () => schema.make("ses_" + descending())
return {
create,
descending: (id?: string) => (id === undefined ? create() : schema.make(id)),
fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)),
}
}),
)
export type ID = typeof ID.Type
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
id: ID,
parentID: ID.pipe(optionalOmitUndefined),
projectID: Project.ID,
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
archived: DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
}).annotate({ identifier: "SessionV2.Info" })
export const ListAnchor = Schema.Struct({
id: ID,
time: Schema.Finite,
direction: Schema.Literals(["previous", "next"]),
})
export type ListAnchor = typeof ListAnchor.Type
+18
View File
@@ -0,0 +1,18 @@
export * as Workspace from "./workspace"
import { Schema } from "effect"
import { ascending } from "./identifier"
import { withStatics } from "./schema"
export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(
Schema.brand("WorkspaceV2.ID"),
withStatics((schema) => ({
ascending: (id?: string) => {
if (id === undefined) return schema.make("wrk_" + ascending())
if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`)
return schema.make(id)
},
create: () => schema.make("wrk_" + ascending()),
})),
)
export type ID = typeof ID.Type
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+27
View File
@@ -0,0 +1,27 @@
# @opencode-ai/sdk-next
Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
```ts
import { OpenCode } from "@opencode-ai/sdk-next"
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.
The same constructor is available as a service Layer:
```ts
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.get({ sessionID })
})
yield * program.pipe(Effect.provide(OpenCode.layer))
```
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk-next",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
+16
View File
@@ -0,0 +1,16 @@
export * as OpenCode from "./opencode"
export { ClientError } from "@opencode-ai/client/effect"
export { Tool } from "@opencode-ai/core/public/tool"
export {
AbsolutePath,
Agent,
Location,
Model,
Prompt,
Provider,
RelativePath,
Session,
SessionInput,
SessionMessage,
} from "@opencode-ai/client/effect"
+49
View File
@@ -0,0 +1,49 @@
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"
export const create = Effect.fn("OpenCode.create")(function* () {
const { handler, permissions, tools } = yield* Effect.all({
handler: HttpRouter.toHttpEffect(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices))),
permissions: PermissionSaved.Service,
tools: ApplicationTools.Service,
}).pipe(Effect.provide(Layer.merge(ApplicationTools.layer, 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 client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
)
return {
...client,
tools: { register: tools.register },
}
})
export type Interface = Effect.Success<ReturnType<typeof create>>
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
export const layer = Layer.effect(Service, create())
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from "bun:test"
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"
test("embedded client uses the real router and handlers", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
try {
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.create()
yield* opencode.tools.register({
embedded_tool: Tool.make({
description: "Embedded test tool",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
}),
})
const created = yield* opencode.sessions.create({
id: sessionID,
agent: Agent.ID.make("build"),
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
const selected = yield* opencode.sessions.get({ sessionID })
const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
const admitted = yield* opencode.sessions.prompt({
sessionID,
prompt: Prompt.make({ text: "Do not run" }),
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()}`) }),
)
expect(created.id).toBe(sessionID)
expect(selected.model?.id).toBe(model.id)
expect(selected.model?.providerID).toBe(model.providerID)
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")
})
await Effect.runPromise(Effect.scoped(program))
} finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true })
}
})
test("embedded client is available as a Layer service", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
const database = Flag.OPENCODE_DB
Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
try {
const created = await Effect.runPromise(
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
})
}).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
)
expect(created.id).toBe(sessionID)
} finally {
Flag.OPENCODE_DB = database
await rm(directory, { recursive: true, force: true })
}
})
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const client = resolve(import.meta.dir, "../../client")
const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
test("bundles the client and in-memory host", async () => {
const inputs = await bundleInputs()
expect(within(inputs, client).length).toBeGreaterThan(0)
expect(within(inputs, core).length).toBeGreaterThan(0)
expect(within(inputs, server).length).toBeGreaterThan(0)
})
async function bundleInputs() {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
const child = Bun.spawn(
[
process.execPath,
"build",
entrypoint,
"--target=bun",
"--format=esm",
"--packages=bundle",
`--metafile=${metafile}`,
`--outdir=${join(temporary, "out")}`,
],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function within(inputs: ReadonlyArray<string>, directory: string) {
const prefix = directory.endsWith(sep) ? directory : directory + sep
return inputs.filter((input) => input === directory || input.startsWith(prefix))
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
+1 -1
View File
@@ -3,7 +3,7 @@ import { SchemaErrorMiddleware } from "./middleware/schema-error"
import { MessageGroup } from "./groups/message"
import { ModelGroup } from "./groups/model"
import { ProviderGroup } from "./groups/provider"
import { SessionGroup } from "./groups/session"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { PermissionGroup } from "./groups/permission"
import { FileSystemGroup } from "./groups/fs"
import { CommandGroup } from "./groups/command"
+1 -101
View File
@@ -1,101 +1 @@
import { Schema } from "effect"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
{
message: Schema.String,
kind: Schema.optional(Schema.String),
field: Schema.optional(Schema.String),
},
{ httpApiStatus: 400 },
) {}
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
"ConflictError",
{
message: Schema.String,
resource: Schema.optional(Schema.String),
},
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
message: Schema.String,
service: Schema.optional(Schema.String),
},
{ httpApiStatus: 503 },
) {}
export class UnknownError extends Schema.TaggedErrorClass<UnknownError>()(
"UnknownError",
{
message: Schema.String,
ref: Schema.optional(Schema.String),
},
{ httpApiStatus: 500 },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"ProviderNotFoundError",
{
providerID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
"PermissionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
"QuestionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
"ForbiddenError",
{ message: Schema.String },
{ httpApiStatus: 403 },
) {}
export class PtyNotFoundError extends Schema.TaggedErrorClass<PtyNotFoundError>()(
"PtyNotFoundError",
{
ptyID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export * from "@opencode-ai/protocol/errors"

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