Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 5ee0c123ec fix(opencode): use generic reauthentication guidance 2026-06-23 12:11:28 -05:00
Aiden Cline 075b02a15e fix(opencode): recover expired websocket auth 2026-06-23 12:03:35 -05:00
136 changed files with 1348 additions and 6319 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: 💬 Discord Community
url: https://discord.gg/opencode
about: For support, troubleshooting, how-to questions, and real-time discussion.
about: For quick questions or real-time discussion. Note that issues are searchable and help others with the same question.
+10
View File
@@ -0,0 +1,10 @@
name: Question
description: Ask a question
body:
- type: textarea
id: question
attributes:
label: Question
description: What's your question?
validations:
required: true
-5
View File
@@ -69,11 +69,6 @@ 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,21 +67,6 @@ 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**.
@@ -132,51 +117,6 @@ _Avoid_: Response envelope
- **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.
@@ -193,23 +133,6 @@ _Avoid_: Response envelope
- **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,27 +109,6 @@
"@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",
@@ -298,7 +277,6 @@
"@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",
@@ -498,18 +476,6 @@
"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",
@@ -685,30 +651,6 @@
"@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": {
@@ -719,20 +661,6 @@
"@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",
@@ -753,7 +681,6 @@
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -1017,7 +944,6 @@
"@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",
@@ -1848,8 +1774,6 @@
"@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"],
@@ -1876,22 +1800,14 @@
"@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,7 +34,6 @@
"@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",
+1 -3
View File
@@ -185,9 +185,7 @@ function TargetDirectoryLayout(props: ParentProps) {
if (!search.draftId) return undefined
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory
})
const directory = createMemo<string | undefined>((prev) =>
search.draftId ? resolvedDirectory() : (prev ?? resolvedDirectory()),
)
const directory = createMemo<string | undefined>((prev) => prev ?? resolvedDirectory())
const home = () => !params.serverKey && !search.draftId
const targetDirectory = () => directory()!
+1 -1
View File
@@ -1379,7 +1379,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource(
() => prompt.ready.promise,
() => prompt.ready().promise,
(p) => p,
)
@@ -27,7 +27,7 @@ let variant: string | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
ready: () => Object.assign(() => true, { promise: Promise.resolve(true) }),
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
+17 -34
View File
@@ -29,6 +29,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -263,7 +264,15 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const serverSdk = useServerSDK()
const navigate = useNavigate()
const layout = useLayout()
const global = useGlobal()
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
const tabs = useTabs()
const tabsStore = tabs.store
@@ -328,28 +337,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.removeSessions(detail)
})
const openNewTab = () => {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.createServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const openNewTab = () => navigate(newSessionHref())
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
command.register("titlebar-home", () => [
@@ -524,16 +512,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}
const [session] = createResource(
() => {
const id = tab.sessionId
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return { id, sdk }
},
({ id, sdk }) =>
sdk.client.session
.get({ sessionID: id })
() => tab.sessionId,
(sessionID) =>
serverSdk()
.client.session.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined),
)
@@ -610,7 +592,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")}
/>
</Show>
+4 -11
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
@@ -181,7 +181,7 @@ function promptTarget(serverScope: ServerScope, scope: Scope) {
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
createStore<PromptStore>(promptStore()),
@@ -190,12 +190,6 @@ export function createPromptSession(serverScope: ServerScope, scope: Scope) {
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
@@ -253,7 +247,7 @@ export function createPromptState() {
const [store, setStore] = createStore<PromptStore>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
ready: () => ready,
...createPromptStateValue(store, setStore),
}
}
@@ -314,10 +308,9 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
ready: () => session().ready,
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
+2 -2
View File
@@ -1,4 +1,4 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createEffect, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
@@ -28,7 +28,7 @@ export default function NewLayout(props: ParentProps) {
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar update={update} />
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
{props.children}
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
@@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
@@ -25,12 +25,11 @@ import { pathKey } from "@/utils/path-key"
import { useLocal } from "@/context/local"
import { useProviders } from "@/hooks/use-providers"
import { useSettings } from "@/context/settings"
import { ServerConnection, useServer } from "@/context/server"
import { type DraftTab, useTabs } from "@/context/tabs"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useDirectoryPicker } from "@/components/directory-picker"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -74,52 +73,26 @@ export function SessionComposerRegion(props: {
const settings = useSettings()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const view = layout.view(route.sessionKey)
const draft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const projectServer = createMemo(() => {
if (!search.draftId) return server.current
const target = draft()?.server
if (!target) return
return server.list.find((conn) => ServerConnection.key(conn) === target)
})
const projectServerCtx = createMemo(() => {
const conn = projectServer()
if (conn) return global.createServerCtx(conn)
})
const projects = createMemo(() =>
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(),
)
const agentsQuery = createQuery(() => queryOptions().agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => queryOptions().providers(null))
const providersQuery = createQuery(() => queryOptions().providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => {
const conn = projectServer()
const target = projectServerCtx()
if (search.draftId) {
if (!conn || !target) return
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
layout.projects.open(worktree)
server.projects.touch(worktree)
if (search.draftId) {
tabs.updateDraft(search.draftId, { server: server.key, directory: worktree })
return
}
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = (title: string) => {
const conn = projectServer()
if (!conn) return
if (!server.current) return
pickDirectory({
server: conn,
server: server.current,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
@@ -142,7 +115,7 @@ export function SessionComposerRegion(props: {
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
projects: {
available: projects(),
available: layout.projects.list(),
directory: sdk().directory,
select: selectProject,
add: addProject,
@@ -243,12 +216,6 @@ export function SessionComposerRegion(props: {
update()
})
const ready = Promise.resolve()
const [promptReadyResource] = createResource(
() => prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return (
<div
ref={props.setPromptDockRef}
@@ -291,7 +258,7 @@ export function SessionComposerRegion(props: {
<Show when={showComposer()}>
<Show
when={promptReadyResource()}
when={prompt.ready()}
fallback={
<>
<Show when={rolled()} keyed>
@@ -1,69 +0,0 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import type { AsyncStorage } from "@solid-primitives/storage"
import { createEffect, createRoot } from "solid-js"
import { ServerScope } from "@/utils/server-scope"
let Prompt: typeof import("@/context/prompt")
let read: ((value: string | null) => void) | undefined
const storage: AsyncStorage = {
getItem: () => new Promise((resolve) => (read = resolve)),
setItem: async () => undefined,
removeItem: async () => undefined,
clear: async () => undefined,
key: async () => null,
getLength: async () => 0,
length: Promise.resolve(0),
}
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
useSearchParams: () => [{}],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
use: () => undefined,
provider: () => undefined,
}),
}))
mock.module("@/context/platform", () => ({
usePlatform: () => ({ platform: "desktop", storage: () => storage }),
}))
Prompt = await import("@/context/prompt")
})
describe("prompt persistence", () => {
test("waits for an async draft to hydrate before reporting ready", async () => {
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" })
const ready = Prompt.createPromptReady(() => session)
expect(ready()).toBe(false)
expect(session.current()[0]).toMatchObject({ type: "text", content: "" })
read?.(
JSON.stringify({
prompt: [{ type: "text", content: "persisted draft", start: 0, end: 15 }],
cursor: 15,
context: { items: [] },
}),
)
createEffect(() => {
if (!ready()) return
try {
expect(session.current()[0]).toMatchObject({ type: "text", content: "persisted draft" })
dispose()
resolve()
} catch (error) {
dispose()
reject(error)
}
})
})
})
})
})
-27
View File
@@ -1,27 +0,0 @@
# @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
@@ -1,37 +0,0 @@
{
"$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
@@ -1,26 +0,0 @@
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
@@ -1,12 +0,0 @@
// 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"
@@ -1,5 +0,0 @@
[
"client-error.ts",
"client.ts",
"index.ts"
]
@@ -1,5 +0,0 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -1,136 +0,0 @@
// 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))
@@ -1,2 +0,0 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -1,6 +0,0 @@
[
"client-error.ts",
"client.ts",
"index.ts",
"types.ts"
]
@@ -1,11 +0,0 @@
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
@@ -1,309 +0,0 @@
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
@@ -1,3 +0,0 @@
export { ClientError, type ClientErrorReason } from "./client-error"
export * as OpenCode from "./client"
export * from "./types"
-555
View File
@@ -1,555 +0,0 @@
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
@@ -1 +0,0 @@
export * from "./generated/index"
@@ -1,50 +0,0 @@
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
@@ -1,98 +0,0 @@
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,
},
}
@@ -1,68 +0,0 @@
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
@@ -1,117 +0,0 @@
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
@@ -1,9 +0,0 @@
{
"$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,7 +91,6 @@
"@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",
+2 -3
View File
@@ -1,14 +1,13 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Array, Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
export const ID = Agent.ID
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const defaultID = ID.make("build")
+26 -82
View File
@@ -1,4 +1,4 @@
import { Context, Data, Effect, Layer, LayerMap, Scope } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
@@ -48,48 +48,12 @@ import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
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 }),
})
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", {
directory: locationRef.directory,
workspaceID: locationRef.workspaceID,
}),
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(locationRef)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
@@ -159,45 +123,25 @@ class LocationServiceCache extends LayerMap.Service<LocationServiceCache>()("@op
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [...locationServiceDependencies, ApplicationTools.layer],
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,
],
}) {}
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))),
)
}
+4 -3
View File
@@ -1,13 +1,14 @@
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 const Ref = SchemaLocation.Ref
export type Ref = SchemaLocation.Ref
export class Ref extends Schema.Class<Ref>("Location.Ref")({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}) {}
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
+7 -4
View File
@@ -1,12 +1,11 @@
import { Schema, Types } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
export const ID = Model.ID
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export type ID = typeof ID.Type
export const VariantID = Model.VariantID
export const VariantID = Schema.String.pipe(Schema.brand("VariantID"))
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
@@ -34,7 +33,11 @@ export const Cost = Schema.Struct({
}),
})
export const Ref = Model.Ref
export const Ref = Schema.Struct({
id: ID,
providerID: ProviderV2.ID,
variant: VariantID.pipe(Schema.optional),
})
export type Ref = typeof Ref.Type
export const Api = Schema.Union([
+7 -3
View File
@@ -1,10 +1,14 @@
export * as ProjectSchema from "./schema"
import { Schema } from "effect"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "../schema"
import { AbsolutePath, withStatics } from "../schema"
export const ID = Project.ID
export const ID = Schema.String.pipe(
Schema.brand("Project.ID"),
withStatics((schema) => ({
global: schema.make("global"),
})),
)
export type ID = typeof ID.Type
export const Vcs = Schema.Union([
+18 -2
View File
@@ -1,9 +1,25 @@
export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema, Types } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
export const ID = Provider.ID
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 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.layerWithApplicationTools.pipe(Layer.provide(ApplicationTools.layer))),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
+58 -22
View File
@@ -1,27 +1,47 @@
import { Schema } from "effect"
import {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
type ExternalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
RelativePath,
withStatics,
} from "@opencode-ai/schema/schema"
import { Option, Schema, SchemaGetter } from "effect"
import { Hash } from "./util/hash"
export {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
RelativePath,
withStatics,
export type ExternalID = {
readonly namespace: string
readonly key: string
}
export type { ExternalID }
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)),
}),
)
/**
* Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
@@ -51,6 +71,22 @@ 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.
+5 -2
View File
@@ -2,7 +2,6 @@ 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"
@@ -39,7 +38,11 @@ import { SessionInput } from "./session/input"
// - by subpath
// - by workspace (home is special)
export const ListAnchor = SchemaSession.ListAnchor
export const ListAnchor = Schema.Struct({
id: SessionSchema.ID,
time: Schema.Finite,
direction: Schema.Literals(["previous", "next"]),
})
export type ListAnchor = typeof ListAnchor.Type
const ListInputBase = {
+4 -2
View File
@@ -2,10 +2,12 @@ import { Schema } from "effect"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema"
import { NonNegativeInt } from "../schema"
import { V2Schema } from "../v2-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 }
@@ -20,7 +22,7 @@ export const Source = Schema.Struct({
export type Source = typeof Source.Type
const Base = {
timestamp: DateTimeUtcFromMillis,
timestamp: V2Schema.DateTimeUtcFromMillis,
sessionID: SessionSchema.ID,
}
const PromptFields = {
+14 -6
View File
@@ -2,9 +2,10 @@ 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"
@@ -13,17 +14,24 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export const Delivery = SchemaSessionInput.Delivery
export const Delivery = Schema.Literals(["steer", "queue"])
export type Delivery = typeof Delivery.Type
export const Admitted = SchemaSessionInput.Admitted
export type Admitted = SchemaSessionInput.Admitted
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),
}) {}
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
Admitted.make({
new Admitted({
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
@@ -68,7 +76,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
event.durable === undefined
? Effect.die("Prompt admission event is missing aggregate sequence")
: Effect.succeed(
Admitted.make({
new Admitted({
admittedSeq: event.durable.seq,
id: input.id,
sessionID: input.sessionID,
+12 -1
View File
@@ -1,2 +1,13 @@
export * as SessionMessageID from "./message-id"
export { ID } from "@opencode-ai/schema/session-message"
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
+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(
SessionMessage.AgentSwitched.make({
new SessionMessage.AgentSwitched({
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(
SessionMessage.ModelSwitched.make({
new SessionMessage.ModelSwitched({
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(
SessionMessage.User.make({
new SessionMessage.User({
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(
SessionMessage.System.make({
new SessionMessage.System({
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(
SessionMessage.Synthetic.make({
new SessionMessage.Synthetic({
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(
SessionMessage.Shell.make({
new SessionMessage.Shell({
id: event.data.messageID,
type: "shell",
metadata: event.metadata,
@@ -194,7 +194,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
}
yield* adapter.appendMessage(
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
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(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
castDraft(new SessionMessage.AssistantText({ 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(
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
state: new SessionMessage.ToolStatePending({ 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(
SessionMessage.ToolStateRunning.make({
new SessionMessage.ToolStateRunning({
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(
SessionMessage.ToolStateCompleted.make({
new SessionMessage.ToolStateCompleted({
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(
SessionMessage.ToolStateError.make({
new SessionMessage.ToolStateError({
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(
SessionMessage.AssistantReasoning.make({
new SessionMessage.AssistantReasoning({
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(
SessionMessage.Compaction.make({
new SessionMessage.Compaction({
id: event.data.messageID,
type: "compaction",
metadata: event.metadata,
+192 -1
View File
@@ -1,2 +1,193 @@
export * as SessionMessage from "./message"
export * from "@opencode-ai/schema/session-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"]
+46 -1
View File
@@ -1 +1,46 @@
export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
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 }),
})
}
}
+44 -6
View File
@@ -1,11 +1,49 @@
export * as SessionSchema from "./schema"
import { Session } from "@opencode-ai/schema/session"
import type { ExternalID } 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"
export const ID = Session.ID
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 type ID = typeof ID.Type
export type { ExternalID }
export const Info = Session.Info
export type Info = Session.Info
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),
}) {}
+10
View File
@@ -0,0 +1,10 @@
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"
+14 -2
View File
@@ -1,6 +1,18 @@
export * as WorkspaceV2 from "./workspace"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
export const ID = Workspace.ID
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 type ID = typeof ID.Type
+3 -2
View File
@@ -4,8 +4,9 @@ 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, DateTimeUtcFromMillis } from "@opencode-ai/core/schema"
import { AbsolutePath } 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"
@@ -78,7 +79,7 @@ const SyncTimestamp = EventV2.define({
},
schema: {
id: Schema.String,
timestamp: DateTimeUtcFromMillis,
timestamp: V2Schema.DateTimeUtcFromMillis,
},
})
+10 -20
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Schema } from "effect"
import { DateTime, Effect, Equal, Hash, 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.layerWithApplicationTools.pipe(
LocationServiceMap.layer.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
@@ -52,24 +52,14 @@ const it = testEffect(
)
describe("LocationServiceMap", () => {
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.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("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: Prompt.make({ text: "Hello" }), resume: false })
yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ 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: Prompt.make({ text: "Replay lifecycle" }),
prompt: new Prompt({ 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(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
} = encodeMessage(new SessionMessage.Assistant({ 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: Prompt.make({ text: "first" }),
prompt: new Prompt({ 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: Prompt.make({ text: "second" }),
prompt: new Prompt({ 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: Prompt.make({ text: "promote me" }),
prompt: new Prompt({ 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: Prompt.make({ text: "promote me" }),
prompt: new Prompt({ 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 = SessionMessage.Assistant.make({
const stale = new SessionMessage.Assistant({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
@@ -342,7 +342,7 @@ describe("SessionProjector", () => {
content: [],
time: { created },
})
const completed = SessionMessage.Assistant.make({
const completed = new SessionMessage.Assistant({
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([
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: SessionMessage.ID.make("msg_assistant_completed"),
type: "assistant",
agent: "build",
model,
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })],
content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}),
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: SessionMessage.ID.make("msg_assistant_stale"),
type: "assistant",
agent: "build",
+23 -28
View File
@@ -147,7 +147,7 @@ describe("SessionV2.prompt", () => {
const message = yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ 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: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ 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: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ 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: Prompt.make({ text: "Fix the failing tests" }), resume: false }
const input = { sessionID, prompt: new Prompt({ 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: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
}
@@ -255,7 +255,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: Prompt.make({ text: "Recover committed prompt" }),
prompt: new Prompt({ 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: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ text: "Fix the failing tests" }),
})
const failure = yield* session
.prompt({
sessionID,
id: messageID,
prompt: Prompt.make({ text: "Delete the failing tests" }),
prompt: new Prompt({ text: "Delete the failing tests" }),
resume: false,
})
.pipe(Effect.flip)
@@ -301,14 +301,14 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
id: messageID,
sessionID,
prompt: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ text: "Fix the failing tests" }),
resume: false,
})
const failure = yield* session
.prompt({
id: messageID,
sessionID,
prompt: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ text: "Fix the failing tests" }),
delivery: "queue",
resume: false,
})
@@ -325,7 +325,7 @@ describe("SessionV2.prompt", () => {
const input = {
sessionID,
id: messageID,
prompt: Prompt.make({ text: "Fix the failing tests" }),
prompt: new Prompt({ 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: Prompt.make({ text: "Promote once" }), resume: false })
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ 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: Prompt.make({ text: "Before cutoff" }), resume: false })
const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false })
const cutoff = first.admittedSeq
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false })
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
@@ -386,12 +386,7 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
wakeCalls.length = 0
yield* session.prompt({
id: messageID,
sessionID,
prompt: Prompt.make({ text: "Replay pending" }),
resume: false,
})
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false })
const recorded = yield* db
.select()
.from(EventTable)
@@ -427,7 +422,7 @@ describe("SessionV2.prompt", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = Prompt.make({ text: "Historical prompt" })
const prompt = new Prompt({ text: "Historical prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
@@ -448,7 +443,7 @@ describe("SessionV2.prompt", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const prompt = Prompt.make({ text: "Historical queued prompt" })
const prompt = new Prompt({ text: "Historical queued prompt" })
yield* events.publish(SessionEvent.Prompted, {
sessionID,
messageID,
@@ -483,7 +478,7 @@ describe("SessionV2.prompt", () => {
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const prompt = Prompt.make({ text: "Fix the failing tests" })
const prompt = new Prompt({ text: "Fix the failing tests" })
yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
const failure = yield* session
@@ -507,7 +502,7 @@ describe("SessionV2.prompt", () => {
})
const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false })
.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false })
.pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })
@@ -522,7 +517,7 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
@@ -538,7 +533,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Run explicitly" }),
prompt: new Prompt({ text: "Run explicitly" }),
resume: true,
})
@@ -554,7 +549,7 @@ describe("SessionV2.prompt", () => {
executionCalls.length = 0
wakeCalls.length = 0
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ 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"]) =>
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: id(value),
type: "assistant",
agent: "build",
@@ -27,13 +27,13 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages(
[
assistant("empty", []),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]),
assistant("empty-reasoning", [
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }),
]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]),
assistant("reasoning", [
SessionMessage.AssistantReasoning.make({
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning",
text: "",
@@ -48,43 +48,43 @@ describe("toLLMMessages", () => {
})
test("maps every top-level V2 Session message type", () => {
const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const messages = toLLMMessages(
[
SessionMessage.AgentSwitched.make({
new SessionMessage.AgentSwitched({
id: id("agent"),
type: "agent-switched",
agent: "build",
time: { created },
}),
SessionMessage.ModelSwitched.make({
new SessionMessage.ModelSwitched({
id: id("model"),
type: "model-switched",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
time: { created },
}),
SessionMessage.System.make({
new SessionMessage.System({
id: id("system"),
type: "system",
text: "Updated context\n\nOther context",
time: { created },
}),
SessionMessage.User.make({
new SessionMessage.User({
id: id("user"),
type: "user",
text: "Inspect this image",
files: [file],
agents: [AgentAttachment.make({ name: "build" })],
agents: [new AgentAttachment({ name: "build" })],
time: { created },
}),
SessionMessage.Synthetic.make({
new SessionMessage.Synthetic({
id: id("synthetic"),
type: "synthetic",
sessionID: SessionV2.ID.make("ses_translate"),
text: "Synthetic context",
time: { created },
}),
SessionMessage.Shell.make({
new SessionMessage.Shell({
id: id("shell"),
type: "shell",
callID: "shell-1",
@@ -92,7 +92,7 @@ describe("toLLMMessages", () => {
output: "/project",
time: { created, completed: created },
}),
SessionMessage.Compaction.make({
new SessionMessage.Compaction({
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(
[
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: id("assistant"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
SessionMessage.AssistantReasoning.make({
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-1",
text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "pending",
name: "read",
state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
time: { created },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "running",
name: "read",
state: SessionMessage.ToolStateRunning.make({
state: new SessionMessage.ToolStateRunning({
status: "running",
input: { path: "README.md" },
content: [],
@@ -174,11 +174,11 @@ Recent work
}),
time: { created },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "completed",
name: "read",
state: SessionMessage.ToolStateCompleted.make({
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { path: "README.md" },
content: [
@@ -194,7 +194,7 @@ Recent work
}),
time: { created, completed: created },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted",
name: "web_search",
@@ -203,7 +203,7 @@ Recent work
metadata: { fake: { continuation: "hosted-call" } },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: SessionMessage.ToolStateCompleted.make({
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [{ type: "text", text: "Found it" }],
@@ -211,12 +211,12 @@ Recent work
}),
time: { created, completed: created },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "hosted-failed",
name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
state: SessionMessage.ToolStateError.make({
state: new SessionMessage.ToolStateError({
status: "error",
input: { path: "README.md" },
content: [],
@@ -299,13 +299,13 @@ Recent work
test("restores OpenAI encrypted reasoning metadata", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: id("assistant-openai-reasoning"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
new SessionMessage.AssistantReasoning({
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(
[
SessionMessage.Assistant.make({
new SessionMessage.Assistant({
id: id("assistant-old-model"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantReasoning.make({
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
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: SessionMessage.ToolStateCompleted.make({
state: new SessionMessage.ToolStateCompleted({
status: "completed",
input: { query: "Effect" },
content: [],
@@ -360,7 +360,7 @@ Recent work
}),
time: { created, completed: created },
}),
SessionMessage.AssistantTool.make({
new SessionMessage.AssistantTool({
type: "tool",
id: "local-old-model",
name: "read",
@@ -369,7 +369,7 @@ Recent work
metadata: { fake: { call: "old" } },
resultMetadata: { fake: { result: "old" } },
},
state: SessionMessage.ToolStateCompleted.make({
state: new SessionMessage.ToolStateCompleted({
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: Prompt.make({ text: "Say hello in one short sentence." }),
prompt: new Prompt({ text: "Say hello in one short sentence." }),
resume: false,
})
+111 -110
View File
@@ -26,6 +26,7 @@ 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"
@@ -350,7 +351,7 @@ const setupOverflowRecovery = Effect.gen(function* () {
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Earlier question ".repeat(700) }),
prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -475,7 +476,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: Prompt.make({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ 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
@@ -506,7 +507,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: Prompt.make({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure))
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
@@ -528,7 +529,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: Prompt.make({ text: prompt }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false })
responseStream = Stream.concat(
Stream.fromIterable(fixture.partialEvents),
Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)),
@@ -572,7 +573,7 @@ describe("SessionRunnerLLM", () => {
}),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false })
responses = [
[
LLMEvent.stepStart({ index: 0 }),
@@ -620,7 +621,7 @@ describe("SessionRunnerLLM", () => {
streamStarted = undefined
response = []
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) })
expect(requests).toHaveLength(1)
expect(yield* session.messages({ sessionID })).toMatchObject([
@@ -633,8 +634,8 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
responses = undefined
@@ -661,7 +662,7 @@ describe("SessionRunnerLLM", () => {
const { db } = yield* Database.Service
const messageID = SessionMessage.ID.create()
systemUnavailable = true
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
@@ -679,7 +680,7 @@ describe("SessionRunnerLLM", () => {
).toBeUndefined()
systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
@@ -692,7 +693,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
@@ -710,7 +711,7 @@ describe("SessionRunnerLLM", () => {
.get(),
).toBeUndefined()
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
@@ -724,7 +725,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* db
@@ -733,7 +734,7 @@ describe("SessionRunnerLLM", () => {
.where(eq(SessionContextEpochTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
@@ -748,13 +749,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -789,7 +790,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-build", ["Done"]).completeEvents
@@ -815,7 +816,7 @@ describe("SessionRunnerLLM", () => {
editor.default(AgentV2.ID.make("reviewer"))
})
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents
@@ -844,7 +845,7 @@ describe("SessionRunnerLLM", () => {
.run()
.pipe(Effect.orDie)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents
@@ -861,7 +862,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: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -873,7 +874,7 @@ describe("SessionRunnerLLM", () => {
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -904,7 +905,7 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -934,7 +935,7 @@ describe("SessionRunnerLLM", () => {
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -948,13 +949,13 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemRemoved = true
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
@@ -970,13 +971,13 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
@@ -985,7 +986,7 @@ describe("SessionRunnerLLM", () => {
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1005,7 +1006,7 @@ describe("SessionRunnerLLM", () => {
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
}),
)
@@ -1015,7 +1016,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -1027,11 +1028,11 @@ describe("SessionRunnerLLM", () => {
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
systemUnavailable = false
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1047,7 +1048,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
@@ -1068,7 +1069,7 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
@@ -1076,7 +1077,7 @@ describe("SessionRunnerLLM", () => {
["Replacement context"],
])
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
}),
)
@@ -1088,7 +1089,7 @@ describe("SessionRunnerLLM", () => {
response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Earlier question ".repeat(180) }),
prompt: new Prompt({ text: "Earlier question ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1101,7 +1102,7 @@ describe("SessionRunnerLLM", () => {
]
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }),
prompt: new Prompt({ text: "Recent exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1127,7 +1128,7 @@ describe("SessionRunnerLLM", () => {
]
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }),
prompt: new Prompt({ text: "Newest exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
@@ -1155,7 +1156,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1185,7 +1186,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
overflow(),
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1213,7 +1214,7 @@ describe("SessionRunnerLLM", () => {
fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
@@ -1231,7 +1232,7 @@ describe("SessionRunnerLLM", () => {
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
@@ -1254,7 +1255,7 @@ describe("SessionRunnerLLM", () => {
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
streamGate = summaryGate
@@ -1274,13 +1275,13 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
@@ -1298,7 +1299,7 @@ describe("SessionRunnerLLM", () => {
recent: "",
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
@@ -1310,7 +1311,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false })
requests.length = 0
responses = undefined
@@ -1408,7 +1409,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
requests.length = 0
authorizations.length = 0
@@ -1467,7 +1468,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
requests.length = 0
responses = [
@@ -1511,7 +1512,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false })
requests.length = 0
response = [
@@ -1549,7 +1550,7 @@ describe("SessionRunnerLLM", () => {
},
])
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
response = []
yield* session.resume(sessionID)
@@ -1568,7 +1569,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false })
requests.length = 0
response = [
@@ -1593,7 +1594,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
response = []
yield* session.resume(sessionID)
@@ -1623,7 +1624,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false })
requests.length = 0
executions.length = 0
@@ -1684,7 +1685,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false })
requests.length = 0
executions.length = 0
@@ -1772,7 +1773,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false })
requests.length = 0
responses = undefined
@@ -1811,7 +1812,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -1831,7 +1832,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -1854,7 +1855,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -1882,7 +1883,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Wait until continuation ends" }),
prompt: new Prompt({ text: "Wait until continuation ends" }),
delivery: "queue",
})
yield* Deferred.succeed(streamGate, undefined)
@@ -1902,7 +1903,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
@@ -1920,7 +1921,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Run after interrupt" }),
prompt: new Prompt({ text: "Run after interrupt" }),
delivery: "queue",
})
yield* session.interrupt(sessionID)
@@ -1945,7 +1946,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
@@ -1963,7 +1964,7 @@ describe("SessionRunnerLLM", () => {
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Steer after interrupt" }),
prompt: new Prompt({ text: "Steer after interrupt" }),
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
@@ -1987,7 +1988,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2012,8 +2013,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
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* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2030,10 +2031,10 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering" }), resume: false })
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Queue for later" }),
prompt: new Prompt({ text: "Queue for later" }),
delivery: "queue",
resume: false,
})
@@ -2064,7 +2065,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2095,13 +2096,13 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
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* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" })
streamGate = secondGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
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* 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* Deferred.succeed(secondGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2129,7 +2130,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = [
@@ -2149,8 +2150,8 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
streamGate = undefined
@@ -2169,7 +2170,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false })
requests.length = 0
responses = undefined
@@ -2180,7 +2181,7 @@ describe("SessionRunnerLLM", () => {
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) })
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure)
@@ -2199,7 +2200,7 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ 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, {
@@ -2261,7 +2262,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
prompt: new Prompt({ text: "Recover interrupted hosted tool" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
@@ -2321,7 +2322,7 @@ describe("SessionRunnerLLM", () => {
const events = yield* EventV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
prompt: new Prompt({ text: "Recover interrupted tool input" }),
resume: false,
})
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
@@ -2359,7 +2360,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Wait in queue" }),
prompt: new Prompt({ text: "Wait in queue" }),
delivery: "queue",
resume: false,
})
@@ -2381,7 +2382,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: Prompt.make({ text: "Recover promoted input" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
fail = false
@@ -2409,7 +2410,7 @@ describe("SessionRunnerLLM", () => {
)
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Run committed promotion" }),
prompt: new Prompt({ text: "Run committed promotion" }),
resume: false,
})
@@ -2426,8 +2427,8 @@ describe("SessionRunnerLLM", () => {
yield* setup
yield* insertSession(otherSessionID)
const session = yield* SessionV2.Service
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 })
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 })
requests.length = 0
responses = undefined
@@ -2469,12 +2470,12 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID: externalSessionID,
prompt: Prompt.make({ text: "Run external session" }),
prompt: new Prompt({ text: "Run external session" }),
resume: false,
})
yield* session.prompt({
sessionID: otherExternalSessionID,
prompt: Prompt.make({ text: "Run other external session" }),
prompt: new Prompt({ text: "Run other external session" }),
resume: false,
})
@@ -2493,7 +2494,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false })
requests.length = 0
responses = undefined
@@ -2524,7 +2525,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false })
requests.length = 0
responses = [
@@ -2570,7 +2571,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false })
requests.length = 0
responses = [
@@ -2619,7 +2620,7 @@ describe("SessionRunnerLLM", () => {
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false })
requests.length = 0
responses = [
@@ -2664,7 +2665,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false })
const failure = providerUnavailable()
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
@@ -2698,7 +2699,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false })
executions.length = 0
toolExecutionGate = yield* Deferred.make<void>()
responseStream = Stream.concat(
@@ -2748,7 +2749,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false })
requests.length = 0
response = []
streamGate = yield* Deferred.make<void>()
@@ -2771,7 +2772,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false })
executions.length = 0
toolExecutionGate = yield* Deferred.make<void>()
response = [
@@ -2814,7 +2815,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
requests.length = 0
executions.length = 0
@@ -2862,7 +2863,7 @@ describe("SessionRunnerLLM", () => {
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start work" }), resume: false })
requests.length = 0
executions.length = 0
@@ -2890,7 +2891,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) })
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
streamGate = undefined
@@ -2908,7 +2909,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false })
requests.length = 0
responses = undefined
@@ -2930,7 +2931,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false })
requests.length = 0
response = [LLMEvent.providerError({ message: "Provider unavailable" })]
@@ -2949,7 +2950,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false })
requests.length = 0
response = [
@@ -2978,7 +2979,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false })
const failure = providerUnavailable()
responseStream = Stream.fail(failure)
@@ -2997,7 +2998,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Do not continue failed provider" }),
prompt: new Prompt({ text: "Do not continue failed provider" }),
resume: false,
})
@@ -3020,7 +3021,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false })
requests.length = 0
response = [
@@ -3051,7 +3052,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false })
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({
@@ -3078,7 +3079,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }),
prompt: new Prompt({ text: "Fail hosted tool on raw failure" }),
resume: false,
})
const failure = providerUnavailable()
@@ -3113,7 +3114,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false })
responses = undefined
streamGate = undefined
@@ -3176,7 +3177,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false })
responses = undefined
streamGate = undefined
-42
View File
@@ -1,42 +0,0 @@
# @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
@@ -1,22 +0,0 @@
{
"$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
@@ -1,28 +0,0 @@
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
@@ -1,45 +0,0 @@
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 }),
),
)
@@ -1,897 +0,0 @@
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 })",
)
})
})
@@ -1,28 +0,0 @@
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
@@ -1,5 +0,0 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -1,16 +0,0 @@
// 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))
@@ -1,39 +0,0 @@
// 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) })
@@ -1,2 +0,0 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -1,96 +0,0 @@
// 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),
})
@@ -1,25 +0,0 @@
// 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
@@ -1,160 +0,0 @@
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
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
+2 -21
View File
@@ -1,6 +1,6 @@
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { pathToFileURL } from "node:url"
import { SessionV1 } from "@opencode-ai/core/v1/session"
export type PromptPart = SessionV1.TextPartInput | SessionV1.FilePartInput
@@ -76,26 +76,7 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
case "resource":
if ("text" in block.resource) {
try {
const parsed = new URL(block.resource.uri)
if (parsed.protocol === "file:") {
const line = parsed.hash.match(/^#L(\d+)/)?.[1]
let filepath: string
try {
filepath = fileURLToPath(parsed)
} catch {
filepath = decodeURIComponent(parsed.pathname)
}
if (path.sep === "\\") filepath = filepath.replace(/\\/g, "/")
return [
{
type: "text",
text: `[${filepath}${line ? `:${line}` : ""}]\n${block.resource.text}`,
},
]
}
} catch {}
return [{ type: "text", text: `[${block.resource.uri}]\n${block.resource.text}` }]
return [{ type: "text", text: block.resource.text }]
}
if (block.resource.mimeType) {
return [
-8
View File
@@ -129,14 +129,6 @@ export function resources(client: Client, timeout?: number) {
)
}
export function resourceTemplates(client: Client, timeout?: number) {
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
return paginate(
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
(result) => result.resourceTemplates,
)
}
function listTools(client: Client, timeout: number) {
return Effect.tryPromise({
try: () =>
+1 -16
View File
@@ -125,7 +125,6 @@ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
type ResourceTemplateInfo = Awaited<ReturnType<MCPClient["listResourceTemplates"]>>["resourceTemplates"][number]
type McpEntry = NonNullable<ConfigV1.Info["mcp"]>[string]
function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info {
@@ -163,9 +162,6 @@ export interface Interface {
readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly resourceTemplates: (
clientName?: string,
) => Effect.Effect<Record<string, ResourceTemplateInfo & { client: string }>>
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void, NotFoundError>
readonly disconnect: (name: string) => Effect.Effect<void, NotFoundError>
@@ -647,7 +643,7 @@ export const layer = Layer.effect(
}
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const mcpTool of listed) {
const key = "mcp__" + McpCatalog.sanitize(clientName) + "__" + McpCatalog.sanitize(mcpTool.name)
const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
}
}
@@ -694,16 +690,6 @@ export const layer = Layer.effect(
)
})
const resourceTemplates = Effect.fn("MCP.resourceTemplates")(function* (clientName?: string) {
return yield* collectFromConnected(
yield* InstanceState.get(state),
McpCatalog.resourceTemplates,
"resource templates",
(template) => template.uriTemplate,
clientName,
)
})
const withClient = Effect.fnUntraced(function* <A>(
clientName: string,
fn: (client: MCPClient, timeout?: number) => Promise<A>,
@@ -945,7 +931,6 @@ export const layer = Layer.effect(
tools,
prompts,
resources,
resourceTemplates,
add,
connect,
disconnect,
+52 -9
View File
@@ -6,12 +6,14 @@ import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
import { escapeHtml } from "@/util/html"
import { ProviderError } from "@/provider/error"
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
@@ -133,6 +135,25 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
}).toString(),
})
if (!response.ok) {
const body = await response.text()
const code = (() => {
try {
const parsed = JSON.parse(body)
return parsed?.error?.code ?? parsed?.code
} catch {
return undefined
}
})()
if (
response.status === 401 ||
code === "refresh_token_expired" ||
code === "refresh_token_reused" ||
code === "refresh_token_invalidated"
) {
throw new ProviderError.AuthenticationError(
"Your ChatGPT login could not be refreshed. Please sign in again.",
)
}
throw new Error(`Token refresh failed: ${response.status}`)
}
return response.json()
@@ -401,7 +422,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
async loader(getAuth) {
const auth = await getAuth()
const websocketFetch = options.experimentalWebSockets
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch, recoverWithHttp: auth.type === "oauth" })
: undefined
if (websocketFetch) {
websocketFetches.push(websocketFetch)
@@ -411,7 +432,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
let refreshPromise:
| Promise<{
refresh: string
access: string
expires: number
accountId: string | undefined
}>
| undefined
@@ -436,24 +459,26 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
if (!currentAuth.access || currentAuth.expires < Date.now()) {
const refresh = async () => {
if (!refreshPromise) {
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
.then(async (tokens) => {
const accountId = extractAccountId(tokens) || authWithAccount.accountId
const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000
await input.client.auth.set({
path: { id: "openai" },
body: {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
expires,
...(accountId && { accountId }),
},
})
return {
refresh: tokens.refresh_token,
access: tokens.access_token,
expires,
accountId,
}
})
@@ -463,10 +488,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
const refreshed = await refreshPromise
currentAuth.refresh = refreshed.refresh
currentAuth.access = refreshed.access
currentAuth.expires = refreshed.expires
authWithAccount.accountId = refreshed.accountId
}
if (!currentAuth.access || currentAuth.expires < Date.now() + TOKEN_REFRESH_WINDOW_MS) await refresh()
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
@@ -482,9 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
}
headers.set("authorization", `Bearer ${currentAuth.access}`)
if (authWithAccount.accountId) {
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
}
if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
const parsed =
requestInput instanceof URL
@@ -499,8 +526,24 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
...init,
headers,
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
const request = () => {
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
}
const response = await request()
if (response.status !== 401) return response
await response.body?.cancel()
const latestAuth = await getAuth()
if (latestAuth.type === "oauth") {
currentAuth.refresh = latestAuth.refresh
currentAuth.access = latestAuth.access
currentAuth.expires = latestAuth.expires
authWithAccount.accountId = (latestAuth as typeof latestAuth & { accountId?: string }).accountId
}
await refresh()
headers.set("authorization", `Bearer ${currentAuth.access}`)
if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
return request()
},
}
},
@@ -12,6 +12,7 @@ export interface CreateWebSocketFetchOptions {
idleTimeout?: number
maxConnectionAge?: number
streamRetries?: number
recoverWithHttp?: boolean
}
interface PoolEntry {
@@ -151,7 +152,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
recordStreamFailure(entry)
invalidate(entry)
if (entry.fallback) return httpFetch(input, httpInit)
if (options?.recoverWithHttp || entry.fallback) return httpFetch(input, httpInit)
return failedResponse(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
+4
View File
@@ -20,6 +20,10 @@ export class ResponseStreamError extends Error {
}
}
export class AuthenticationError extends Error {
public override readonly name = "ProviderAuthenticationError"
}
function isOpenAiErrorRetryable(e: APICallError) {
const status = e.statusCode
if (!status) return e.isRetryable
@@ -54,7 +54,6 @@ 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"
@@ -281,7 +280,6 @@ export function createRoutes(
HttpServer.layerServices,
]),
Layer.provide(LayerNode.buildLayer(app)),
Layer.provide(ApplicationTools.layer),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(Observability.layer),
)
@@ -676,6 +676,14 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof ProviderError.AuthenticationError:
return new AuthError(
{
providerID: ctx.providerID,
message: e.message,
},
{ cause: e },
).toObject()
case e instanceof ProviderError.ResponseStreamError:
return new APIError(
{
+5 -5
View File
@@ -1086,12 +1086,12 @@ export const layer = Layer.effect(
}
if (part.type === "file") {
result.files.push(
FileAttachment.make({
new FileAttachment({
uri: part.url,
mime: part.mime,
name: part.filename,
source: part.source
? Source.make({
? new Source({
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(
AgentAttachment.make({
new AgentAttachment({
name: part.name,
source: part.source
? Source.make({
? new Source({
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: Prompt.make({
prompt: new Prompt({
text: nextPrompt.text.join("\n"),
files: nextPrompt.files,
agents: nextPrompt.agents,
+8 -99
View File
@@ -22,11 +22,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { isRecord } from "@/util/record"
const MCP_RESOURCE_TOOLS = {
list: "list_mcp_resources",
listTemplates: "list_mcp_resource_templates",
read: "read_mcp_resource",
} as const
const LIST_MCP_RESOURCES_TOOL = "list_mcp_resources"
const READ_MCP_RESOURCE_TOOL = "read_mcp_resource"
const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024
const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"application/pdf",
@@ -133,7 +130,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
(client) => !!client.getServerCapabilities()?.resources,
)
if (hasMcpResourceServer) {
tools[MCP_RESOURCE_TOOLS.list] = tool({
tools[LIST_MCP_RESOURCES_TOOL] = tool({
description:
"Lists resources provided by connected MCP servers. Resources provide context such as files, database schemas, or application-specific information.",
inputSchema: jsonSchema(
@@ -170,7 +167,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
@@ -203,7 +200,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.list, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
{ tool: LIST_MCP_RESOURCES_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
@@ -215,90 +212,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
},
})
tools[MCP_RESOURCE_TOOLS.listTemplates] = tool({
description:
"Lists resource templates provided by connected MCP servers. Resource templates are parameterized resources that can be read after filling in their URI template.",
inputSchema: jsonSchema(
ProviderTransform.schema(input.model, {
type: "object",
properties: {
server: {
type: "string",
description:
"Optional MCP server name. When omitted, lists resource templates from every connected server.",
},
},
additionalProperties: false,
}),
),
execute(args, opts) {
return run.promise(
Effect.gen(function* () {
const parsed = parseListMcpResourcesArgs(args)
const ctx = context(toRecord(args), opts)
const clients = yield* mcp.clients()
const resourceServers = Object.entries(clients)
.filter((entry) => !!entry[1].getServerCapabilities()?.resources)
.map((entry) => entry[0])
.sort((a, b) => a.localeCompare(b))
if (parsed.server && !resourceServers.includes(parsed.server)) {
throw new Error(
resourceServers.length === 0
? `MCP server "${parsed.server}" does not support resources`
: `MCP server "${parsed.server}" does not support resources. Available resource servers: ${resourceServers.join(", ")}`,
)
}
const permissionPatterns = parsed.server
? [`mcp:${parsed.server}:*`]
: resourceServers.map((server) => `mcp:${server}:*`)
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
permission: "read",
metadata: parsed.server ? { server: parsed.server } : {},
patterns: permissionPatterns,
always: permissionPatterns,
})
const templates = Object.values(yield* mcp.resourceTemplates(parsed.server))
const filtered = templates
.filter((template) => !parsed.server || template.client === parsed.server)
.toSorted((a, b) =>
(a.client + "\u0000" + a.name + "\u0000" + a.uriTemplate).localeCompare(
b.client + "\u0000" + b.name + "\u0000" + b.uriTemplate,
),
)
const content = JSON.stringify({ resourceTemplates: filtered.map(formatMcpResourceTemplate) }, null, 2)
const truncated = yield* truncate.output(content, {}, input.agent)
const output = {
title: parsed.server ? `MCP resource templates: ${parsed.server}` : "MCP resource templates",
metadata: {
count: filtered.length,
servers: resourceServers,
...(parsed.server ? { server: parsed.server } : {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
},
output: truncated.content,
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.listTemplates, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
yield* input.processor.completeToolCall(opts.toolCallId, output)
}
return output
}),
)
},
})
tools[MCP_RESOURCE_TOOLS.read] = tool({
tools[READ_MCP_RESOURCE_TOOL] = tool({
description:
"Read a specific resource from an MCP server using the server name and resource URI. The URI is an MCP identifier and does not need to be a file URL.",
inputSchema: jsonSchema(
@@ -333,7 +247,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}
yield* plugin.trigger(
"tool.execute.before",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
yield* ctx.ask({
@@ -368,7 +282,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}
yield* plugin.trigger(
"tool.execute.after",
{ tool: MCP_RESOURCE_TOOLS.read, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
{ tool: READ_MCP_RESOURCE_TOOL, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
output,
)
if (opts.abortSignal?.aborted) {
@@ -518,11 +432,6 @@ function formatMcpResource(resource: MCP.Resource) {
return { ...result, server: resource.client }
}
function formatMcpResourceTemplate(template: Record<string, unknown> & { client: string }) {
const result = Object.fromEntries(Object.entries(template).filter((entry) => entry[0] !== "client"))
return { ...result, server: template.client }
}
function formatMcpResourceContent(server: string, uri: string, content: { contents: unknown }) {
const items = (Array.isArray(content.contents) ? content.contents : [content.contents]).filter(isRecord)
const text: string[] = []
+10 -19
View File
@@ -82,9 +82,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
const encodeNulTerminatedPaths = (files: string[]) => files.join("\0") + "\0"
const encodeTopLevelLiteralPathspecs = (files: string[]) =>
encodeNulTerminatedPaths(files.map((file) => `:(top,literal)${file}`))
const feed = (list: string[]) => list.join("\0") + "\0"
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
@@ -109,8 +107,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
const ignore = Effect.fnUntraced(function* (files: string[]) {
if (!files.length) return new Set<string>()
// check-ignore treats a leading colon as pathspec magic but accepts and echoes a protective ./ prefix.
const checkIgnorePaths = files.map((item) => (item.startsWith(":") ? `./${item}` : item))
const check = yield* git(
[
...quote,
@@ -124,17 +120,12 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
"-z",
],
{
cwd: state.worktree,
stdin: encodeNulTerminatedPaths(checkIgnorePaths),
cwd: state.directory,
stdin: feed(files),
},
)
if (check.code !== 0 && check.code !== 1) return new Set<string>()
return new Set(
check.text
.split("\0")
.filter(Boolean)
.map((item) => (item.startsWith("./:") ? item.slice(2) : item)),
)
return new Set(check.text.split("\0").filter(Boolean))
})
const drop = Effect.fnUntraced(function* (files: string[]) {
@@ -145,8 +136,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
],
{
cwd: state.worktree,
stdin: encodeTopLevelLiteralPathspecs(files),
cwd: state.directory,
stdin: feed(files),
},
)
})
@@ -156,8 +147,8 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
const result = yield* git(
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
{
cwd: state.worktree,
stdin: encodeTopLevelLiteralPathspecs(files),
cwd: state.directory,
stdin: feed(files),
},
)
if (result.code === 0) return
@@ -247,7 +238,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
}),
git([...quote, ...args(["ls-files", "--full-name", "--others", "--exclude-standard", "-z", "--", "."])], {
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
}),
],
@@ -286,7 +277,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Serv
(yield* Effect.all(
allow.map((item) =>
fs
.stat(path.join(state.worktree, item))
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
+4 -38
View File
@@ -99,51 +99,17 @@ describe("acp content conversion", () => {
])
})
test("resource with text becomes a sourced text part", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt#L12-L14",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
expect(result[0].text.includes("12")).toBe(true)
}
})
test("resource with text uses URI fallback for non-file resources", () => {
test("resource with text becomes a text part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "mcp://server/context",
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
}),
).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
})
test("resource with text includes file path", () => {
const result = contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
})
expect(result).toHaveLength(1)
expect(result[0]?.type).toBe("text")
if (result[0]?.type === "text") {
expect(result[0].text.endsWith("\ncontext")).toBe(true)
expect(result[0].text.includes("context.txt")).toBe(true)
}
).toEqual([{ type: "text", text: "context" }])
})
test("resource with blob and mimeType becomes a data URL file part", () => {
+3 -31
View File
@@ -17,7 +17,6 @@ interface MockClientState {
listToolsCalls: number
listPromptsCalls: number
listResourcesCalls: number
listResourceTemplatesCalls: number
getPromptTimeout?: number
readResourceTimeout?: number
requestCalls: number
@@ -27,7 +26,6 @@ interface MockClientState {
listResourcesShouldFail: boolean
prompts: Array<{ name: string; description?: string }>
resources: Array<{ name: string; uri: string; description?: string }>
resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>
toolPages: Record<
string,
{
@@ -40,10 +38,6 @@ interface MockClientState {
string,
{ resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string }
>
resourceTemplatePages: Record<
string,
{ resourceTemplates: Array<{ name: string; uriTemplate: string; description?: string }>; nextCursor?: string }
>
closed: boolean
clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } }
requestHandlers: Map<unknown, (...args: any[]) => Promise<any>>
@@ -73,7 +67,6 @@ function getOrCreateClientState(name?: string): MockClientState {
listToolsCalls: 0,
listPromptsCalls: 0,
listResourcesCalls: 0,
listResourceTemplatesCalls: 0,
requestCalls: 0,
listToolsShouldFail: false,
listToolsError: "listTools failed",
@@ -81,11 +74,9 @@ function getOrCreateClientState(name?: string): MockClientState {
listResourcesShouldFail: false,
prompts: [],
resources: [],
resourceTemplates: [],
toolPages: {},
promptPages: {},
resourcePages: {},
resourceTemplatePages: {},
closed: false,
requestHandlers: new Map(),
notificationHandlers: new Map(),
@@ -233,13 +224,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
return { resources: this._state?.resources ?? [] }
}
async listResourceTemplates(params?: { cursor?: string }) {
if (this._state) this._state.listResourceTemplatesCalls++
const page = this._state?.resourceTemplatePages[params === undefined ? "initial" : (params.cursor ?? "")]
if (page) return page
return { resourceTemplates: this._state?.resourceTemplates ?? [] }
}
async getPrompt(_params: unknown, options?: { timeout?: number }) {
if (this._state) this._state.getPromptTimeout = options?.timeout
return { messages: [] }
@@ -369,30 +353,18 @@ it.instance(
initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" },
"resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] },
}
serverState.resourceTemplatePages = {
initial: {
resourceTemplates: [{ name: "template-one", uriTemplate: "test://one/{id}" }],
nextCursor: "resource-templates-2",
},
"resource-templates-2": { resourceTemplates: [{ name: "template-two", uriTemplate: "test://two/{id}" }] },
}
yield* mcp.add("paged-server", {
type: "local",
command: ["echo", "test"],
})
expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__paged-server__tool-one", "mcp__paged-server__tool-two"])
expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"])
expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"])
expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:test://one", "paged-server:test://two"])
expect(Object.keys(yield* mcp.resourceTemplates())).toEqual([
"paged-server:test://one/{id}",
"paged-server:test://two/{id}",
])
expect(serverState.listToolsCalls).toBe(2)
expect(serverState.listPromptsCalls).toBe(2)
expect(serverState.listResourcesCalls).toBe(2)
expect(serverState.listResourceTemplatesCalls).toBe(2)
}),
),
{ config: { mcp: {} } },
@@ -944,7 +916,7 @@ it.instance(
expect(statusName(result.status, "tools-only-server")).toBe("connected")
expect(serverState.listToolsCalls).toBe(1)
expect(Object.keys(yield* mcp.tools())).toEqual(["mcp__tools-only-server__test_tool"])
expect(Object.keys(yield* mcp.tools())).toEqual(["tools-only-server_test_tool"])
expect(yield* mcp.prompts()).toEqual({})
expect(yield* mcp.resources()).toEqual({})
expect(serverState.listPromptsCalls).toBe(0)
@@ -1137,7 +1109,7 @@ it.instance(
const keys = Object.keys(tools)
// Server name dots should be replaced with underscores
expect(keys.some((k) => k.startsWith("mcp__my_special-server__"))).toBe(true)
expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
// Tool name dots should be replaced with underscores
expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
expect(keys.length).toBe(2)
+90 -2
View File
@@ -7,6 +7,7 @@ import {
renderOAuthError,
type IdTokenClaims,
} from "../../src/plugin/openai/codex"
import { ProviderError } from "../../src/provider/error"
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
@@ -153,8 +154,8 @@ describe("plugin.codex", () => {
let auth = {
type: "oauth" as const,
refresh: "refresh-old",
access: "",
expires: 0,
access: "access-old",
expires: Date.now() + 60_000,
}
const authUpdates: Array<{
body: { refresh: string; access: string; expires: number; accountId?: string }
@@ -245,8 +246,95 @@ describe("plugin.codex", () => {
{ authorization: "Bearer access-new", accountId: "acc-123" },
])
})
test("refreshes and retries once after an unauthorized response", async () => {
let auth = {
type: "oauth" as const,
refresh: "refresh-old",
access: "access-old",
expires: Date.now() + 60 * 60 * 1000,
}
const authorizations: Array<string | null> = []
let refreshRequests = 0
using server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/oauth/token") {
refreshRequests += 1
return Response.json({
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
access_token: "access-new",
refresh_token: "refresh-new",
expires_in: 3600,
})
}
if (url.pathname === "/backend-api/codex/responses") {
authorizations.push(request.headers.get("authorization"))
return new Response("{}", { status: authorizations.length === 1 ? 401 : 200 })
}
return new Response("unexpected request", { status: 500 })
},
})
const hooks = await CodexAuthPlugin(
pluginInput(async (next) => {
auth = { type: "oauth", ...next.body }
}),
{
issuer: server.url.origin,
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
},
)
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
const response = await loaded.fetch!("https://api.openai.com/v1/responses")
expect(response.status).toBe(200)
expect(refreshRequests).toBe(1)
expect(authorizations).toEqual(["Bearer access-old", "Bearer access-new"])
})
test("requests reauthentication when token refresh is permanently rejected", async () => {
const auth = {
type: "oauth" as const,
refresh: "refresh-old",
access: "access-old",
expires: 0,
}
using server = Bun.serve({
port: 0,
fetch() {
return Response.json({ error: { code: "refresh_token_invalidated" } }, { status: 401 })
},
})
const hooks = await CodexAuthPlugin(
pluginInput(async () => {}),
{ issuer: server.url.origin },
)
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
const error = await loaded.fetch!("https://api.openai.com/v1/responses").catch((error: unknown) => error)
expect(error).toBeInstanceOf(ProviderError.AuthenticationError)
expect(error.message).toBe("Your ChatGPT login could not be refreshed. Please sign in again.")
})
})
function pluginInput(
set: (input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) => Promise<void>,
) {
return {
client: { auth: { set } },
project: {},
directory: "",
worktree: "",
experimental_workspace: { register() {} },
serverUrl: new URL("https://example.com"),
$: {},
} as never
}
async function waitFor(predicate: () => boolean) {
const started = Date.now()
while (!predicate()) {
@@ -188,6 +188,23 @@ describe("plugin.openai.ws-pool", () => {
fetch.close()
})
test("recovers websocket connection failures over HTTP when requested", async () => {
let attempts = 0
await using server = await createRejectingWebSocketServer(() => attempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
recoverWithHttp: true,
httpFetch: Object.assign(async () => new Response("unauthorized", { status: 401 }), { preconnect() {} }),
})
const response = await fetch(server.url, streamRequest())
expect(response.status).toBe(401)
expect(await response.text()).toBe("unauthorized")
expect(attempts).toBe(1)
fetch.close()
})
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
const attempts: string[] = []
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
@@ -16,7 +16,6 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly operationId?: string
readonly parameters?: ReadonlyArray<{
readonly name: string
readonly in: string
@@ -69,18 +68,6 @@ 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,6 +23,7 @@ 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"
@@ -128,7 +129,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
Effect.gen(function* () {
const message = SessionMessage.Assistant.make({
const message = new SessionMessage.Assistant({
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
@@ -116,7 +116,6 @@ const mcp = Layer.succeed(
tools: () => Effect.succeed({}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
resourceTemplates: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
@@ -182,6 +182,13 @@ describe("session.retry.retryable", () => {
})
})
test("does not retry provider authentication errors", () => {
const request = MessageV2.fromError(new ProviderError.AuthenticationError("Sign in again"), { providerID })
expect(SessionV1.AuthError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toBeUndefined()
})
test("does not retry context overflow errors", () => {
const error = new SessionV1.ContextOverflowError({
message: "Input exceeds context window of this model",
@@ -40,7 +40,6 @@ const mcp = Layer.succeed(
tools: () => Effect.succeed({}),
prompts: () => Effect.succeed({}),
resources: () => Effect.succeed({}),
resourceTemplates: () => Effect.succeed({}),
add: () => Effect.succeed({ status: { status: "disabled" as const } }),
connect: () => Effect.void,
disconnect: () => Effect.void,
@@ -16,8 +16,6 @@ import {
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer))
// Windows forbids both * and : in directory names.
const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
// with path.join (which produces \ on Windows) then normalizes back to /.
@@ -450,97 +448,6 @@ it.live(
}),
)
it.live(
"subdirectory snapshots include scoped changes only",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const frontend = path.join(dir, "frontend")
yield* write(`${frontend}/tracked.txt`, "initial")
yield* write(`${frontend}/deleted.txt`, "initial")
yield* write(`${dir}/backend/tracked.txt`, "initial")
yield* write(`${dir}/backend/deleted.txt`, "initial")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${frontend}/tracked.txt`, "changed")
yield* write(`${frontend}/untracked.txt`, "new")
yield* rm(`${frontend}/deleted.txt`)
yield* write(`${dir}/backend/tracked.txt`, "changed")
yield* rm(`${dir}/backend/deleted.txt`)
const patch = yield* snapshot.patch(before!)
const diff = yield* snapshot.diff(before!)
expect(patch.files).toContain(fwd(frontend, "tracked.txt"))
expect(patch.files).toContain(fwd(frontend, "untracked.txt"))
expect(patch.files).toContain(fwd(frontend, "deleted.txt"))
expect(patch.files).not.toContain(fwd(dir, "backend", "tracked.txt"))
expect(patch.files).not.toContain(fwd(dir, "backend", "deleted.txt"))
expect(diff).not.toContain("backend/tracked.txt")
expect(diff).not.toContain("backend/deleted.txt")
}).pipe(provideInstance(frontend))
}),
)
nonWindowsIt(
"subdirectory snapshots treat wildcard characters literally",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = path.join(dir, "src*")
yield* write(`${subdir}/file.txt`, "initial")
yield* write(`${subdir}/later-ignored.txt`, "initial")
yield* write(`${dir}/srca/file.txt`, "initial")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/file.txt`, "changed")
yield* write(`${subdir}/later-ignored.txt`, "changed")
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
yield* write(`${dir}/srca/file.txt`, "changed")
const patch = yield* snapshot.patch(before!)
const diff = yield* snapshot.diff(before!)
expect(patch.files).toContain(fwd(subdir, "file.txt"))
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
expect(patch.files).not.toContain(fwd(dir, "srca", "file.txt"))
expect(diff).toContain("src*/later-ignored.txt")
expect(diff).toContain("deleted file mode")
expect(diff).not.toContain("srca/file.txt")
}).pipe(provideInstance(subdir))
}),
)
nonWindowsIt(
"subdirectory snapshots treat leading colons literally",
Effect.gen(function* () {
const dir = yield* scopedGitTmpdir()
const subdir = path.join(dir, ":src")
yield* write(`${subdir}/kept.txt`, "initial")
yield* write(`${subdir}/later-ignored.txt`, "initial")
yield* exec(dir, ["git", "add", "."])
yield* exec(dir, ["git", "commit", "-m", "init"])
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.track()
expect(before).toBeTruthy()
yield* write(`${subdir}/kept.txt`, "changed")
yield* write(`${subdir}/later-ignored.txt`, "changed")
yield* write(`${subdir}/.gitignore`, "later-ignored.txt\n")
const patch = yield* snapshot.patch(before!)
const diff = yield* snapshot.diff(before!)
expect(patch.files).toContain(fwd(subdir, "kept.txt"))
expect(patch.files).toContain(fwd(subdir, ".gitignore"))
expect(patch.files).not.toContain(fwd(subdir, "later-ignored.txt"))
expect(diff).toContain(":src/later-ignored.txt")
expect(diff).toContain("deleted file mode")
}).pipe(provideInstance(subdir))
}),
)
it.instance(
"gitignore changes",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
-25
View File
@@ -1,25 +0,0 @@
{
"$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
@@ -1,77 +0,0 @@
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 },
) {}
@@ -1,7 +0,0 @@
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
@@ -1,30 +0,0 @@
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

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