diff --git a/CONTEXT.md b/CONTEXT.md index c85ab59964..51836a0348 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -153,8 +153,10 @@ _Avoid_: Response envelope - 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 client outputs ship from `@opencode-ai/client` behind isolated root, `/effect`, and `/effect/embedded` exports. The root export has no runtime path to Effect. `/effect` imports only Effect, Model, and Protocol; `/effect/embedded` additionally owns the scoped Core/server host. Bundle-boundary tests enforce these graphs. -- `/effect` and `/effect/embedded` re-export their decoded datatype facade from the client package. The backing values live in Model, so callers do not depend on internal package locations or Core's versioned names. +- 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. @@ -174,7 +176,7 @@ _Avoid_: Response envelope - 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. -- A future Layer constructor may provide **Embedded OpenCode** using the same scoped creation path; it does not define a second implementation. +- 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,11 +195,13 @@ _Avoid_: Response envelope ## Client contract architecture -Semantic values that mean the same thing internally and publicly live in the lightweight Model leaf. Core consumes Model for domain behavior; Protocol composes Model 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 Model and Protocol, and `/effect/embedded` keeps Core plus Server behind the same public client surface. +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 Model and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM. +- 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. diff --git a/bun.lock b/bun.lock index 77ce3dd993..89c9d0dd32 100644 --- a/bun.lock +++ b/bun.lock @@ -112,10 +112,8 @@ "packages/client": { "name": "@opencode-ai/client", "dependencies": { - "@opencode-ai/core": "workspace:*", - "@opencode-ai/model": "workspace:*", "@opencode-ai/protocol": "workspace:*", - "@opencode-ai/server": "workspace:*", + "@opencode-ai/schema": "workspace:*", }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -299,8 +297,8 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", - "@opencode-ai/model": "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", @@ -530,18 +528,6 @@ "@typescript/native-preview": "catalog:", }, }, - "packages/model": { - "name": "@opencode-ai/model", - "dependencies": { - "@noble/hashes": "catalog:", - "effect": "catalog:", - }, - "devDependencies": { - "@tsconfig/bun": "catalog:", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - }, - }, "packages/opencode": { "name": "opencode", "version": "1.17.9", @@ -702,7 +688,19 @@ "packages/protocol": { "name": "@opencode-ai/protocol", "dependencies": { - "@opencode-ai/model": "workspace:*", + "@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": { @@ -721,6 +719,20 @@ "@types/semver": "^7.5.8", }, }, + "packages/sdk-next": { + "name": "@opencode-ai/sdk-next", + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/sdk/js": { "name": "@opencode-ai/sdk", "version": "1.17.9", @@ -1868,16 +1880,18 @@ "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], - "@opencode-ai/model": ["@opencode-ai/model@workspace:packages/model"], - "@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"], diff --git a/packages/client/README.md b/packages/client/README.md index 5668d80a6d..8cdb32ae98 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -6,15 +6,14 @@ Private generation target for clients derived directly from OpenCode's authorita - `@opencode-ai/client`: zero-Effect Promise client using `fetch`. - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. -- `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router. 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 entrypoints use canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/model` package and are re-exported from both Effect entrypoints 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 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, Model, and Protocol and is browser-bundle safe. `/effect/embedded` intentionally retains Core and Server internally. Bundle-boundary tests enforce these import graphs while preserving the public root, `/effect`, and `/effect/embedded` entrypoints. +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. -Until that extraction, Effect consumers construct canonical decoded inputs: +Effect consumers construct canonical decoded inputs: ```ts import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect" @@ -24,31 +23,5 @@ yield * client.sessions.create({ location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }), }) -yield * client.sessions.prompt({ sessionID, prompt: new Prompt({ text: "Hello" }) }) +yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) }) ``` - -The embedded entrypoint exposes a scoped host backed by the same server router, middleware, handlers, and HTTP codecs as the network client: - -```ts -import { OpenCode } from "@opencode-ai/client/effect/embedded" - -const opencode = yield * OpenCode.create() -const session = yield * opencode.sessions.get({ sessionID }) -``` - -It also exposes embedded-only `tools.register(...)`. Closing the owning Effect Scope releases the router resources, location services, fibers, and scoped tool registrations. - -Effect applications can provide the same scoped constructor as a service Layer: - -```ts -const program = Effect.gen(function* () { - const opencode = yield* OpenCode.Service - return yield* opencode.sessions.get({ sessionID }) -}) - -yield * program.pipe(Effect.provide(OpenCode.layer)) -``` - -`OpenCode.layer` is only a dependency-injection adapter over `OpenCode.create()`; it does not define another embedded implementation. - -The beta embedded host currently assumes one active host per database. Multiple hosts sharing durable Session storage require shared process-local execution coordination and remain deferred together with embedded streaming support. diff --git a/packages/client/package.json b/packages/client/package.json index a133f58762..6c6440cf14 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -6,8 +6,7 @@ "license": "MIT", "exports": { ".": "./src/index.ts", - "./effect": "./src/effect.ts", - "./effect/embedded": "./src/effect-embedded.ts" + "./effect": "./src/effect.ts" }, "scripts": { "generate": "bun run script/build.ts", @@ -16,10 +15,8 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@opencode-ai/core": "workspace:*", - "@opencode-ai/model": "workspace:*", - "@opencode-ai/protocol": "workspace:*", - "@opencode-ai/server": "workspace:*" + "@opencode-ai/schema": "workspace:*", + "@opencode-ai/protocol": "workspace:*" }, "peerDependencies": { "effect": "4.0.0-beta.83" diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index e6c42b42a3..6c0aca564e 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -1,11 +1,12 @@ -// TODO: Keep additional network capabilities inside Model and Protocol as the client grows; /effect must never import +// 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/model/agent" -export { Location } from "@opencode-ai/model/location" -export { Model } from "@opencode-ai/model/model" -export { AbsolutePath, RelativePath } from "@opencode-ai/model/schema" -export { Session } from "@opencode-ai/model/session" -export { SessionInput } from "@opencode-ai/model/session-input" -export { SessionMessage } from "@opencode-ai/model/session-message" -export { Prompt } from "@opencode-ai/model/prompt" +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" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 2cd189a986..27bffbb5b3 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -10,7 +10,7 @@ const Api = HttpApi.make("generated").add(SessionGroup) type RawClient = HttpApiClient.ForApi -const mapClientError = (error: E) => +const mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error @@ -130,5 +130,7 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ 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((raw) => ({ sessions: adaptGroup0(raw["server.session"]) }))) + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 3c188de379..cdd6ec990a 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -82,7 +82,7 @@ export function make(options: ClientOptions) { throw new ClientError("UnexpectedStatus", { cause: { status: response.status } }) } - const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { + const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) return responseError(response, descriptor) if (descriptor.empty) { @@ -94,7 +94,7 @@ export function make(options: ClientOptions) { return (await json(response)) as A } - const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ + const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({ async *[Symbol.asyncIterator]() { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) await responseError(response, descriptor) diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index be843f3040..e3e9a0c7d1 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -1,4 +1,5 @@ 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" @@ -6,20 +7,20 @@ 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/model/agent" -import { Location } from "@opencode-ai/model/location" -import { Model } from "@opencode-ai/model/model" -import { Project } from "@opencode-ai/model/project" -import { Provider } from "@opencode-ai/model/provider" -import { Prompt } from "@opencode-ai/model/prompt" -import { Session } from "@opencode-ai/model/session" -import { SessionInput } from "@opencode-ai/model/session-input" -import { SessionMessage } from "@opencode-ai/model/session-message" -import { Workspace } from "@opencode-ai/model/workspace" +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 Model and Protocol values", () => { +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) @@ -34,3 +35,16 @@ test("Core and Server reuse the authoritative Model and Protocol values", () => 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) +}) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 10a0e5d833..5613c3ce77 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test" import { DateTime, Effect } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionInput } from "../src/effect" +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) => @@ -47,7 +47,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { }) const admitted = yield* client.sessions.prompt({ sessionID: Session.ID.make("ses_test"), - prompt: new Prompt({ text: "Hello" }), + prompt: Prompt.make({ text: "Hello" }), resume: false, }) yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) @@ -57,11 +57,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => { }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) - expect(result.page.data[0]).toBeInstanceOf(Session.Info) - expect(result.created).toBeInstanceOf(Session.Info) + 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(result.admitted).toBeInstanceOf(SessionInput.Admitted) - expect(result.admitted.prompt).toBeInstanceOf(Prompt) + 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([]) }) diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts index 4a108b39dd..4875a3a5dc 100644 --- a/packages/client/test/import-boundaries.test.ts +++ b/packages/client/test/import-boundaries.test.ts @@ -5,7 +5,7 @@ import { join, resolve, sep } from "node:path" const directory = resolve(import.meta.dir, "..") const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect")) -const model = resolve(import.meta.dir, "../../model") +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") @@ -15,7 +15,7 @@ describe("public import boundaries", () => { const root = await bundleInputs("@opencode-ai/client", "browser") expect(within(root, effect)).toEqual([]) - expect(within(root, model)).toEqual([]) + expect(within(root, schema)).toEqual([]) expect(within(root, protocol)).toEqual([]) expect(within(root, core)).toEqual([]) expect(within(root, server)).toEqual([]) @@ -23,15 +23,10 @@ describe("public import boundaries", () => { const network = await bundleInputs("@opencode-ai/client/effect", "browser") expect(within(network, effect).length).toBeGreaterThan(0) - expect(within(network, model).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([]) - - const embedded = await bundleInputs("@opencode-ai/client/effect/embedded", "bun") - - expect(within(embedded, core).length).toBeGreaterThan(0) - expect(within(embedded, server).length).toBeGreaterThan(0) }) }) diff --git a/packages/core/package.json b/packages/core/package.json index 07ec3ff302..633da4b92e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", "@opencode-ai/llm": "workspace:*", - "@opencode-ai/model": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 86adfb0278..c07f71e910 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,7 +1,7 @@ export * as AgentV2 from "./agent" import { Array, Context, Effect, Layer, Schema, Types } from "effect" -import { Agent } from "@opencode-ai/model/agent" +import { Agent } from "@opencode-ai/schema/agent" import { ModelV2 } from "./model" import { PermissionSchema } from "./permission/schema" import { ProviderV2 } from "./provider" diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 89ca950025..533d9d298f 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, LayerMap } from "effect" +import { Context, Data, Effect, Layer, LayerMap, Scope } from "effect" import { Location } from "./location" import { Policy } from "./policy" import { Config } from "./config" @@ -48,12 +48,48 @@ import { SessionRunnerModel } from "./session/runner/model" import { SystemContextBuiltIns } from "./system-context/builtins" import { FetchHttpClient } from "effect/unstable/http" -export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => { +class LocationRefKey extends Data.Class<{ + readonly directory: Location.Ref["directory"] + readonly workspaceID: Location.Ref["workspaceID"] | null +}> {} + +const locationRefKey = (ref: Location.Ref) => + new LocationRefKey({ directory: ref.directory, workspaceID: ref.workspaceID ?? null }) + +const locationServiceDependencies = [ + Project.defaultLayer, + EventV2.defaultLayer, + Credential.defaultLayer, + Npm.defaultLayer, + ModelsDev.defaultLayer, + FSUtil.defaultLayer, + Git.defaultLayer, + AppProcess.defaultLayer, + Global.defaultLayer, + Ripgrep.defaultLayer, + Database.defaultLayer, + ProjectDirectories.defaultLayer, + SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), + PermissionSaved.defaultLayer, + RepositoryCache.defaultLayer, + LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), + FetchHttpClient.layer, + ToolOutputStore.defaultCleanupLayer, +] as const + +class LocationServiceCache extends LayerMap.Service()("@opencode/example/LocationServiceCache", { + lookup: (ref: ReturnType) => { + const locationRef = Location.Ref.make({ + directory: ref.directory, + ...(ref.workspaceID === null ? {} : { workspaceID: ref.workspaceID }), + }) const boot = Layer.effectDiscard( - Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }), + Effect.logInfo("booting location services", { + directory: locationRef.directory, + workspaceID: locationRef.workspaceID, + }), ) - const location = Location.layer(ref) + const location = Location.layer(locationRef) const systemContext = SystemContextBuiltIns.locationLayer const base = Layer.mergeAll( location, @@ -123,25 +159,45 @@ export class LocationServiceMap extends LayerMap.Service()(" ).pipe(Layer.fresh) }, idleTimeToLive: "60 minutes", - dependencies: [ - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Git.defaultLayer, - AppProcess.defaultLayer, - Global.defaultLayer, - Ripgrep.defaultLayer, - Database.defaultLayer, - ProjectDirectories.defaultLayer, - SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), - PermissionSaved.defaultLayer, - RepositoryCache.defaultLayer, - LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), - FetchHttpClient.layer, - ToolOutputStore.defaultCleanupLayer, - ApplicationTools.layer, - ], + dependencies: [...locationServiceDependencies, ApplicationTools.layer], }) {} + +type LocationServices = Layer.Success> + +export interface LocationServiceMapService { + readonly get: (ref: Location.Ref) => Layer.Layer + readonly contextEffect: (ref: Location.Ref) => Effect.Effect, never, Scope.Scope> + readonly invalidate: (ref: Location.Ref) => Effect.Effect +} + +const locationServiceMapLayer = ( + service: Context.Service, + cache: Layer.Layer, +) => + 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()( + "@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 = locationServiceMapLayer(this, LocationServiceCache.layer) + static readonly layerWithApplicationTools: Layer.Layer = + locationServiceMapLayer( + this, + LocationServiceCache.layerNoDeps.pipe(Layer.provide(Layer.mergeAll(...locationServiceDependencies))), + ) +} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 7aeb8b64ad..8387b284df 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,13 +1,13 @@ import { Context, Effect, Layer, Schema } from "effect" -import { Location as ModelLocation } from "@opencode-ai/model/location" +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 = ModelLocation.Ref -export type Ref = ModelLocation.Ref +export const Ref = SchemaLocation.Ref +export type Ref = SchemaLocation.Ref export class Info extends Schema.Class("Location.Info")({ directory: AbsolutePath, diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 903e44385a..4f4b33a206 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,5 +1,5 @@ import { Schema, Types } from "effect" -import { Model } from "@opencode-ai/model/model" +import { Model } from "@opencode-ai/schema/model" import { ProviderV2 } from "./provider" import { ModelRequest } from "./model-request" diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index a3358616e3..eed359abad 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -1,7 +1,7 @@ export * as ProjectSchema from "./schema" import { Schema } from "effect" -import { Project } from "@opencode-ai/model/project" +import { Project } from "@opencode-ai/schema/project" import { AbsolutePath } from "../schema" export const ID = Project.ID diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index a5075cb6d8..566c6ca242 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -1,7 +1,7 @@ export * as ProviderV2 from "./provider" import { Schema, Types } from "effect" -import { Provider } from "@opencode-ai/model/provider" +import { Provider } from "@opencode-ai/schema/provider" export const ID = Provider.ID export type ID = typeof ID.Type diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 7d32556340..419d11fcb8 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -28,7 +28,7 @@ const SessionsLayer = SessionV2.layer.pipe( Layer.provide(EventV2.layer), Layer.provide(Database.defaultLayer), Layer.provide(ProjectV2.defaultLayer), - Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))), + Layer.provide(LocationServiceMap.layerWithApplicationTools.pipe(Layer.provide(ApplicationTools.layer))), Layer.orDie, ) // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 76518cb153..7a277397f8 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -1,30 +1,27 @@ import { Schema } from "effect" import { - AbsolutePath as ModelAbsolutePath, + AbsolutePath, + DateTimeUtcFromMillis, + externalID, + type ExternalID, NonNegativeInt, optionalOmitUndefined, PositiveInt, - RelativePath as ModelRelativePath, -} from "@opencode-ai/model/schema" -import { Hash } from "./util/hash" + RelativePath, + withStatics, +} from "@opencode-ai/schema/schema" -export { NonNegativeInt, optionalOmitUndefined, PositiveInt } - -export type ExternalID = { - readonly namespace: string - readonly key: string +export { + AbsolutePath, + DateTimeUtcFromMillis, + externalID, + NonNegativeInt, + optionalOmitUndefined, + PositiveInt, + RelativePath, + withStatics, } - -export const externalID = (prefix: string, input: ExternalID) => - `${prefix}_${Hash.sha256(JSON.stringify([input.namespace, input.key]))}` - -/** - * Integer greater than zero. - */ -export const RelativePath = ModelRelativePath -export type RelativePath = typeof RelativePath.Type -export const AbsolutePath = ModelAbsolutePath -export type AbsolutePath = typeof AbsolutePath.Type +export type { ExternalID } /** * Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable` @@ -54,22 +51,6 @@ export type DeepMutable = T extends string | number | boolean | bigint | symb ? { -readonly [K in keyof T]: DeepMutable } : 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 = - >(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. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e049c78d07..6cec0e38a0 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -2,7 +2,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" -import { Session as ModelSession } from "@opencode-ai/model/session" +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 +39,7 @@ import { SessionInput } from "./session/input" // - by subpath // - by workspace (home is special) -export const ListAnchor = ModelSession.ListAnchor +export const ListAnchor = SchemaSession.ListAnchor export type ListAnchor = typeof ListAnchor.Type const ListInputBase = { diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 88ac4aa267..8c208d643c 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -2,12 +2,10 @@ import { Schema } from "effect" import { ProviderMetadata, ToolContent } from "@opencode-ai/llm" import { EventV2 } from "../event" import { ModelV2 } from "../model" -import { NonNegativeInt } from "../schema" -import { V2Schema } from "../v2-schema" +import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema" import { FileAttachment, Prompt } from "./prompt" import { SessionSchema } from "./schema" import { Location } from "../location" -import { RelativePath } from "../schema" import { SessionMessageID } from "./message-id" export { FileAttachment } @@ -22,7 +20,7 @@ export const Source = Schema.Struct({ export type Source = typeof Source.Type const Base = { - timestamp: V2Schema.DateTimeUtcFromMillis, + timestamp: DateTimeUtcFromMillis, sessionID: SessionSchema.ID, } const PromptFields = { diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index 5f0465bc1a..82b95a101b 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,7 +2,7 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { SessionInput as ModelSessionInput } from "@opencode-ai/model/session-input" +import { SessionInput as SchemaSessionInput } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" import { SessionEvent } from "./event" @@ -13,17 +13,17 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export const Delivery = ModelSessionInput.Delivery +export const Delivery = SchemaSessionInput.Delivery export type Delivery = typeof Delivery.Type -export const Admitted = ModelSessionInput.Admitted -export type Admitted = ModelSessionInput.Admitted +export const Admitted = SchemaSessionInput.Admitted +export type Admitted = SchemaSessionInput.Admitted const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => - new Admitted({ + Admitted.make({ admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), sessionID: SessionSchema.ID.make(row.session_id), @@ -68,7 +68,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( event.durable === undefined ? Effect.die("Prompt admission event is missing aggregate sequence") : Effect.succeed( - new Admitted({ + Admitted.make({ admittedSeq: event.durable.seq, id: input.id, sessionID: input.sessionID, diff --git a/packages/core/src/session/message-id.ts b/packages/core/src/session/message-id.ts index f236e74d66..2b3009c650 100644 --- a/packages/core/src/session/message-id.ts +++ b/packages/core/src/session/message-id.ts @@ -1,2 +1,2 @@ export * as SessionMessageID from "./message-id" -export { ID } from "@opencode-ai/model/session-message" +export { ID } from "@opencode-ai/schema/session-message" diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 4ece3c2d19..430172ca83 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -102,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { yield* SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { return adapter.appendMessage( - new SessionMessage.AgentSwitched({ + SessionMessage.AgentSwitched.make({ id: event.data.messageID, type: "agent-switched", metadata: event.metadata, @@ -113,7 +113,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.model.switched": (event) => { return adapter.appendMessage( - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: event.data.messageID, type: "model-switched", metadata: event.metadata, @@ -125,7 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.moved": () => Effect.void, "session.next.prompted": (event) => { return adapter.appendMessage( - new SessionMessage.User({ + SessionMessage.User.make({ id: event.data.messageID, type: "user", metadata: event.metadata, @@ -139,7 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.prompt.admitted": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( - new SessionMessage.System({ + SessionMessage.System.make({ id: event.data.messageID, type: "system", text: event.data.text, @@ -148,7 +148,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ), "session.next.synthetic": (event) => { return adapter.appendMessage( - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, id: event.data.messageID, @@ -159,7 +159,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.shell.started": (event) => { return adapter.appendMessage( - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: event.data.messageID, type: "shell", metadata: event.metadata, @@ -194,7 +194,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) } yield* adapter.appendMessage( - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: event.data.assistantMessageID, type: "assistant", agent: event.data.agent, @@ -225,7 +225,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.text.started": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( - castDraft(new SessionMessage.AssistantText({ type: "text", id: event.data.textID, text: "" })), + castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })), ) }) }, @@ -245,12 +245,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: event.data.callID, name: event.data.name, time: { created: event.data.timestamp }, - state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), }), ), ) @@ -270,7 +270,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { match.provider = event.data.provider match.time.ran = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateRunning({ + SessionMessage.ToolStateRunning.make({ status: "running", input: event.data.input, structured: {}, @@ -300,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateCompleted({ + SessionMessage.ToolStateCompleted.make({ status: "completed", input: match.state.input, structured: event.data.structured, @@ -323,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } match.time.completed = event.data.timestamp match.state = castDraft( - new SessionMessage.ToolStateError({ + SessionMessage.ToolStateError.make({ status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, @@ -339,7 +339,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.content.push( castDraft( - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: event.data.reasoningID, text: "", @@ -369,7 +369,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.next.compaction.delta": () => Effect.void, "session.next.compaction.ended": (event) => { return adapter.appendMessage( - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: event.data.messageID, type: "compaction", metadata: event.metadata, diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index 5303a02774..47f1baac05 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,2 +1,2 @@ export * as SessionMessage from "./message" -export * from "@opencode-ai/model/session-message" +export * from "@opencode-ai/schema/session-message" diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index a1e4f3c778..b079349c56 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -1 +1 @@ -export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/model/prompt" +export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt" diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index c857cf9161..ed07eefb76 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -1,6 +1,6 @@ export * as SessionSchema from "./schema" -import { Session } from "@opencode-ai/model/session" +import { Session } from "@opencode-ai/schema/session" import type { ExternalID } from "../schema" export const ID = Session.ID diff --git a/packages/core/src/v2-schema.ts b/packages/core/src/v2-schema.ts deleted file mode 100644 index ef558ff46b..0000000000 --- a/packages/core/src/v2-schema.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { DateTimeUtcFromMillis } from "@opencode-ai/model/schema" - -export * as V2Schema from "./v2-schema" diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index 4b3cedf04d..d85bbe4ba4 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -1,6 +1,6 @@ export * as WorkspaceV2 from "./workspace" -import { Workspace } from "@opencode-ai/model/workspace" +import { Workspace } from "@opencode-ai/schema/workspace" export const ID = Workspace.ID export type ID = typeof ID.Type diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 2001de4efe..966964801d 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -4,9 +4,8 @@ import { EventV2 } from "@opencode-ai/core/event" import { Database } from "@opencode-ai/core/database/database" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { AbsolutePath, DateTimeUtcFromMillis } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { V2Schema } from "@opencode-ai/core/v2-schema" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -79,7 +78,7 @@ const SyncTimestamp = EventV2.define({ }, schema: { id: Schema.String, - timestamp: V2Schema.DateTimeUtcFromMillis, + timestamp: DateTimeUtcFromMillis, }, }) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 5bb064b5f8..0ac6f107bf 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema } from "effect" import { Tool } from "@opencode-ai/core/public" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" @@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer const it = testEffect( Layer.merge( Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), - LocationServiceMap.layer.pipe( + LocationServiceMap.layerWithApplicationTools.pipe( Layer.provide(applicationTools), Layer.provide( Layer.mergeAll( @@ -52,14 +52,24 @@ const it = testEffect( ) describe("LocationServiceMap", () => { - it.effect("compares equivalent location refs by value", () => - Effect.sync(() => { - const directory = AbsolutePath.make("/project") - expect(Equal.equals(Location.Ref.make({ directory }), Location.Ref.make({ directory }))).toBe(true) - expect(Hash.hash(Location.Ref.make({ directory }))).toBe( - Hash.hash(Location.Ref.make({ directory, workspaceID: undefined })), - ) - }), + it.live("reuses cached services for equivalent location refs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap + const directory = AbsolutePath.make(dir.path) + const first = yield* locations.contextEffect(Location.Ref.make({ directory })) + const second = yield* locations.contextEffect(Location.Ref.make({ directory, workspaceID: undefined })) + + expect(first).toBe(second) + }), + ), + ), + ), ) it.live("isolates location state while sharing location policy with catalog", () => diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1ae6c011fc..8ef6816324 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -218,7 +218,7 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: new Prompt({ text: "Hello" }), resume: false }) + yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false }) yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER) expect( @@ -238,7 +238,7 @@ describe("SessionV2.create", () => { const created = yield* session.create({ id: SessionV2.ID.make("ses_fresh_target_replay"), location }) const admitted = yield* session.prompt({ sessionID: created.id, - prompt: new Prompt({ text: "Replay lifecycle" }), + prompt: Prompt.make({ text: "Replay lifecycle" }), resume: false, }) yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 76c3290a70..277e1d52f0 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -36,7 +36,7 @@ const assistantRow = ( id: _, type, ...data - } = encodeMessage(new SessionMessage.Assistant({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } @@ -69,7 +69,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_first"), timestamp: created, - prompt: new Prompt({ text: "first" }), + prompt: Prompt.make({ text: "first" }), delivery: "steer", }, { id: EventV2.ID.make("evt_z") }, @@ -80,7 +80,7 @@ describe("SessionProjector", () => { sessionID, messageID: SessionMessage.ID.make("msg_second"), timestamp: created, - prompt: new Prompt({ text: "second" }), + prompt: Prompt.make({ text: "second" }), delivery: "steer", }, { id: EventV2.ID.make("evt_a") }, @@ -145,7 +145,7 @@ describe("SessionProjector", () => { const admitted = yield* SessionInput.admit(db, events, { id, sessionID, - prompt: new Prompt({ text: "promote me" }), + prompt: Prompt.make({ text: "promote me" }), delivery: "steer", }) if (!admitted) return yield* Effect.die("Prompt admission failed") @@ -154,7 +154,7 @@ describe("SessionProjector", () => { sessionID, timestamp: admitted.timeCreated, messageID: id, - prompt: new Prompt({ text: "promote me" }), + prompt: Prompt.make({ text: "promote me" }), delivery: "steer", }) @@ -334,7 +334,7 @@ describe("SessionProjector", () => { it.effect("does not revive a stale incomplete in-memory assistant projection", () => Effect.gen(function* () { - const stale = new SessionMessage.Assistant({ + const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", @@ -342,7 +342,7 @@ describe("SessionProjector", () => { content: [], time: { created }, }) - const completed = new SessionMessage.Assistant({ + const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", @@ -466,15 +466,15 @@ describe("SessionProjector", () => { Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", agent: "build", model, - content: [new SessionMessage.AssistantText({ type: "text", id: "text-stale", text: "" })], + content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, }), - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", agent: "build", diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 842474396e..1ab6c64c61 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -147,7 +147,7 @@ describe("SessionV2.prompt", () => { const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -171,8 +171,8 @@ describe("SessionV2.prompt", () => { const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER) const streamed = Array.from(yield* Fiber.join(fiber)) @@ -198,7 +198,7 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const message = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) @@ -217,7 +217,7 @@ describe("SessionV2.prompt", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - const input = { sessionID, prompt: new Prompt({ text: "Fix the failing tests" }), resume: false } + const input = { sessionID, prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false } const first = yield* session.prompt(input) const second = yield* session.prompt(input) @@ -235,7 +235,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -255,7 +255,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Recover committed prompt" }), + prompt: Prompt.make({ text: "Recover committed prompt" }), resume: false, } const first = yield* session.prompt(input) @@ -276,13 +276,13 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), }) const failure = yield* session .prompt({ sessionID, id: messageID, - prompt: new Prompt({ text: "Delete the failing tests" }), + prompt: Prompt.make({ text: "Delete the failing tests" }), resume: false, }) .pipe(Effect.flip) @@ -301,14 +301,14 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, }) const failure = yield* session .prompt({ id: messageID, sessionID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), delivery: "queue", resume: false, }) @@ -325,7 +325,7 @@ describe("SessionV2.prompt", () => { const input = { sessionID, id: messageID, - prompt: new Prompt({ text: "Fix the failing tests" }), + prompt: Prompt.make({ text: "Fix the failing tests" }), resume: false, } @@ -344,7 +344,7 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Promote once" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false }) yield* Effect.all( [ @@ -368,9 +368,9 @@ describe("SessionV2.prompt", () => { const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service - const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) + const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false }) const cutoff = first.admittedSeq - const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) + const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) @@ -386,7 +386,12 @@ describe("SessionV2.prompt", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service wakeCalls.length = 0 - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Replay pending" }), resume: false }) + yield* session.prompt({ + id: messageID, + sessionID, + prompt: Prompt.make({ text: "Replay pending" }), + resume: false, + }) const recorded = yield* db .select() .from(EventTable) @@ -422,7 +427,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical prompt" }) + const prompt = Prompt.make({ text: "Historical prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -443,7 +448,7 @@ describe("SessionV2.prompt", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - const prompt = new Prompt({ text: "Historical queued prompt" }) + const prompt = Prompt.make({ text: "Historical queued prompt" }) yield* events.publish(SessionEvent.Prompted, { sessionID, messageID, @@ -478,7 +483,7 @@ describe("SessionV2.prompt", () => { .onConflictDoNothing() .run() .pipe(Effect.orDie) - const prompt = new Prompt({ text: "Fix the failing tests" }) + const prompt = Prompt.make({ text: "Fix the failing tests" }) yield* session.prompt({ id: messageID, sessionID, prompt, resume: false }) const failure = yield* session @@ -502,7 +507,7 @@ describe("SessionV2.prompt", () => { }) const failure = yield* session - .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false }) + .prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Conflicting prompt" }), resume: false }) .pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) @@ -517,7 +522,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run by default" }) }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([sessionID]) @@ -533,7 +538,7 @@ describe("SessionV2.prompt", () => { yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run explicitly" }), + prompt: Prompt.make({ text: "Run explicitly" }), resume: true, }) @@ -549,7 +554,7 @@ describe("SessionV2.prompt", () => { executionCalls.length = 0 wakeCalls.length = 0 - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false }) expect(executionCalls).toEqual([]) expect(wakeCalls).toEqual([]) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index cc515742dd..653a192a62 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -16,7 +16,7 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat. describe("toLLMMessages", () => { test("omits empty assistant turns", () => { const assistant = (value: string, content: SessionMessage.Assistant["content"]) => - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id(value), type: "assistant", agent: "build", @@ -27,13 +27,13 @@ describe("toLLMMessages", () => { const messages = toLLMMessages( [ assistant("empty", []), - assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "empty", text: "" })]), + assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), assistant("empty-reasoning", [ - new SessionMessage.AssistantReasoning({ type: "reasoning", id: "empty-reasoning", text: "" }), + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), ]), - assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]), + assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]), assistant("reasoning", [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning", text: "", @@ -48,43 +48,43 @@ describe("toLLMMessages", () => { }) test("maps every top-level V2 Session message type", () => { - const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) + const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( [ - new SessionMessage.AgentSwitched({ + SessionMessage.AgentSwitched.make({ id: id("agent"), type: "agent-switched", agent: "build", time: { created }, }), - new SessionMessage.ModelSwitched({ + SessionMessage.ModelSwitched.make({ id: id("model"), type: "model-switched", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, time: { created }, }), - new SessionMessage.System({ + SessionMessage.System.make({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created }, }), - new SessionMessage.User({ + SessionMessage.User.make({ id: id("user"), type: "user", text: "Inspect this image", files: [file], - agents: [new AgentAttachment({ name: "build" })], + agents: [AgentAttachment.make({ name: "build" })], time: { created }, }), - new SessionMessage.Synthetic({ + SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), - new SessionMessage.Shell({ + SessionMessage.Shell.make({ id: id("shell"), type: "shell", callID: "shell-1", @@ -92,7 +92,7 @@ describe("toLLMMessages", () => { output: "/project", time: { created, completed: created }, }), - new SessionMessage.Compaction({ + SessionMessage.Compaction.make({ id: id("compaction"), type: "compaction", reason: "auto", @@ -142,31 +142,31 @@ Recent work test("replays durable tool media into canonical tool messages without structured base64", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }), - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-1", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "pending", name: "read", - state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "running", name: "read", - state: new SessionMessage.ToolStateRunning({ + state: SessionMessage.ToolStateRunning.make({ status: "running", input: { path: "README.md" }, content: [], @@ -174,11 +174,11 @@ Recent work }), time: { created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "completed", name: "read", - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [ @@ -194,7 +194,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted", name: "web_search", @@ -203,7 +203,7 @@ Recent work metadata: { fake: { continuation: "hosted-call" } }, resultMetadata: { fake: { continuation: "hosted-result" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [{ type: "text", text: "Found it" }], @@ -211,12 +211,12 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-failed", name: "write", provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, - state: new SessionMessage.ToolStateError({ + state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, content: [], @@ -299,13 +299,13 @@ Recent work test("restores OpenAI encrypted reasoning metadata", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-openai", text: "Think", @@ -330,19 +330,19 @@ Recent work test("drops provider-native continuation metadata after a model switch", () => { const messages = toLLMMessages( [ - new SessionMessage.Assistant({ + SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", agent: "build", model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ - new SessionMessage.AssistantReasoning({ + SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "reasoning-old-model", text: "Visible thought", providerMetadata: { anthropic: { signature: "sig_old" } }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "hosted-old-model", name: "web_search", @@ -351,7 +351,7 @@ Recent work metadata: { openai: { itemId: "hosted-old-model" } }, resultMetadata: { openai: { itemId: "hosted-old-model" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, content: [], @@ -360,7 +360,7 @@ Recent work }), time: { created, completed: created }, }), - new SessionMessage.AssistantTool({ + SessionMessage.AssistantTool.make({ type: "tool", id: "local-old-model", name: "read", @@ -369,7 +369,7 @@ Recent work metadata: { fake: { call: "old" } }, resultMetadata: { fake: { result: "old" } }, }, - state: new SessionMessage.ToolStateCompleted({ + state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, content: [], diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 331c5bb48c..926248c0b0 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -154,7 +154,7 @@ describe("SessionRunnerLLM recorded", () => { const session = yield* SessionV2.Service const prompt = yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Say hello in one short sentence." }), + prompt: Prompt.make({ text: "Say hello in one short sentence." }), resume: false, }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 572c599be7..336dd327e4 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -26,7 +26,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" -import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" @@ -351,7 +350,7 @@ const setupOverflowRecovery = Effect.gen(function* () { response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(700) }), resume: false, }) yield* session.resume(sessionID) @@ -476,7 +475,7 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -507,7 +506,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -529,7 +528,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: prompt }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: prompt }), resume: false }) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -573,7 +572,7 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use application context" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use application context" }), resume: false }) responses = [ [ LLMEvent.stepStart({ index: 0 }), @@ -621,7 +620,7 @@ describe("SessionRunnerLLM", () => { streamStarted = undefined response = [] - const message = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run automatically" }) }) + const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) }) expect(requests).toHaveLength(1) expect(yield* session.messages({ sessionID })).toMatchObject([ @@ -634,8 +633,8 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 responses = undefined @@ -662,7 +661,7 @@ describe("SessionRunnerLLM", () => { const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -680,7 +679,7 @@ describe("SessionRunnerLLM", () => { ).toBeUndefined() systemUnavailable = false - yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) + yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) }) expect(requests).toHaveLength(1) expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) @@ -693,7 +692,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) @@ -711,7 +710,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -725,7 +724,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) response = [] yield* session.resume(sessionID) yield* db @@ -734,7 +733,7 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionContextEpochTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -749,13 +748,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -790,7 +789,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-build", ["Done"]).completeEvents @@ -816,7 +815,7 @@ describe("SessionRunnerLLM", () => { editor.default(AgentV2.ID.make("reviewer")) }) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents @@ -845,7 +844,7 @@ describe("SessionRunnerLLM", () => { .run() .pipe(Effect.orDie) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents @@ -862,7 +861,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -874,7 +873,7 @@ describe("SessionRunnerLLM", () => { timestamp: DateTime.makeUnsafe(1), agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -905,7 +904,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -935,7 +934,7 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -949,13 +948,13 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) @@ -971,13 +970,13 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSwitched, { sessionID, @@ -986,7 +985,7 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1006,7 +1005,7 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fourth" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1016,7 +1015,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1028,11 +1027,11 @@ describe("SessionRunnerLLM", () => { model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1048,7 +1047,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] @@ -1069,7 +1068,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1077,7 +1076,7 @@ describe("SessionRunnerLLM", () => { ["Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) }), ) @@ -1089,7 +1088,7 @@ describe("SessionRunnerLLM", () => { response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Earlier question ".repeat(180) }), + prompt: Prompt.make({ text: "Earlier question ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1102,7 +1101,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recent exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Recent exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1128,7 +1127,7 @@ describe("SessionRunnerLLM", () => { ] yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Newest exact request ".repeat(180) }), + prompt: Prompt.make({ text: "Newest exact request ".repeat(180) }), resume: false, }) yield* session.resume(sessionID) @@ -1156,7 +1155,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1186,7 +1185,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents, overflow(), ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1214,7 +1213,7 @@ describe("SessionRunnerLLM", () => { fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents, fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1232,7 +1231,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1255,7 +1254,7 @@ describe("SessionRunnerLLM", () => { const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1275,13 +1274,13 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) requests.length = 0 response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) const compactionID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Compaction.Started, { @@ -1299,7 +1298,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"]) @@ -1311,7 +1310,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Use tools" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Use tools" }), resume: false }) requests.length = 0 responses = undefined @@ -1409,7 +1408,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 authorizations.length = 0 @@ -1468,7 +1467,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo this" }), resume: false }) requests.length = 0 responses = [ @@ -1512,7 +1511,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Think first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Think first" }), resume: false }) requests.length = 0 response = [ @@ -1550,7 +1549,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1569,7 +1568,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Search first" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Search first" }), resume: false }) requests.length = 0 response = [ @@ -1594,7 +1593,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Continue" }), resume: false }) response = [] yield* session.resume(sessionID) @@ -1624,7 +1623,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo five times" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo five times" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1685,7 +1684,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo twice" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false }) requests.length = 0 executions.length = 0 @@ -1773,7 +1772,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run once" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run once" }), resume: false }) requests.length = 0 responses = undefined @@ -1812,7 +1811,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -1832,7 +1831,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -1855,7 +1854,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -1883,7 +1882,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait until continuation ends" }), + prompt: Prompt.make({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -1903,7 +1902,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -1921,7 +1920,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run after interrupt" }), + prompt: Prompt.make({ text: "Run after interrupt" }), delivery: "queue", }) yield* session.interrupt(sessionID) @@ -1946,7 +1945,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt current work" }), resume: false }) requests.length = 0 responses = [ @@ -1964,7 +1963,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Steer after interrupt" }), + prompt: Prompt.make({ text: "Steer after interrupt" }), }) yield* session.interrupt(sessionID) expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) @@ -1988,7 +1987,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2013,8 +2012,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2031,10 +2030,10 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Queue for later" }), + prompt: Prompt.make({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2065,7 +2064,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2096,13 +2095,13 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue first" }), delivery: "queue" }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Queue second" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue first" }), delivery: "queue" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Queue second" }), delivery: "queue" }) streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer before next queued input" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2130,7 +2129,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = [ @@ -2150,8 +2149,8 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First steer" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First steer" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second steer" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2170,7 +2169,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start working" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start working" }), resume: false }) requests.length = 0 responses = undefined @@ -2181,7 +2180,7 @@ describe("SessionRunnerLLM", () => { const first = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover with this" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover with this" }) }) yield* Deferred.succeed(streamGate, undefined) expect(yield* Fiber.join(first).pipe(Effect.flip)).toBe(streamFailure) @@ -2200,7 +2199,7 @@ describe("SessionRunnerLLM", () => { yield* setup const session = yield* SessionV2.Service const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover interrupted tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2262,7 +2261,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted hosted tool" }), + prompt: Prompt.make({ text: "Recover interrupted hosted tool" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2322,7 +2321,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Recover interrupted tool input" }), + prompt: Prompt.make({ text: "Recover interrupted tool input" }), resume: false, }) yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER) @@ -2360,7 +2359,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait in queue" }), + prompt: Prompt.make({ text: "Wait in queue" }), delivery: "queue", resume: false, }) @@ -2382,7 +2381,7 @@ describe("SessionRunnerLLM", () => { const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false @@ -2410,7 +2409,7 @@ describe("SessionRunnerLLM", () => { ) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Run committed promotion" }), + prompt: Prompt.make({ text: "Run committed promotion" }), resume: false, }) @@ -2427,8 +2426,8 @@ describe("SessionRunnerLLM", () => { yield* setup yield* insertSession(otherSessionID) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run first" }), resume: false }) - yield* session.prompt({ sessionID: otherSessionID, prompt: new Prompt({ text: "Run second" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run first" }), resume: false }) + yield* session.prompt({ sessionID: otherSessionID, prompt: Prompt.make({ text: "Run second" }), resume: false }) requests.length = 0 responses = undefined @@ -2470,12 +2469,12 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID: externalSessionID, - prompt: new Prompt({ text: "Run external session" }), + prompt: Prompt.make({ text: "Run external session" }), resume: false, }) yield* session.prompt({ sessionID: otherExternalSessionID, - prompt: new Prompt({ text: "Run other external session" }), + prompt: Prompt.make({ text: "Run other external session" }), resume: false, }) @@ -2494,7 +2493,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Retry after failure" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Retry after failure" }), resume: false }) requests.length = 0 responses = undefined @@ -2525,7 +2524,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call missing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call missing" }), resume: false }) requests.length = 0 responses = [ @@ -2571,7 +2570,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call defect" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call defect" }), resume: false }) requests.length = 0 responses = [ @@ -2620,7 +2619,7 @@ describe("SessionRunnerLLM", () => { questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie), }), }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Ask then stop" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false }) requests.length = 0 responses = [ @@ -2665,7 +2664,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Settle before failing" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Settle before failing" }), resume: false }) const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -2699,7 +2698,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt blocked tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt blocked tool" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -2749,7 +2748,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt provider" }), resume: false }) requests.length = 0 response = [] streamGate = yield* Deferred.make() @@ -2772,7 +2771,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt tool settlement" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Interrupt tool settlement" }), resume: false }) executions.length = 0 toolExecutionGate = yield* Deferred.make() response = [ @@ -2815,7 +2814,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Finish at the limit" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2863,7 +2862,7 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start work" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Start work" }), resume: false }) requests.length = 0 executions.length = 0 @@ -2891,7 +2890,7 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Change direction" }) }) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) streamGate = undefined @@ -2909,7 +2908,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail durably" }), resume: false }) requests.length = 0 responses = undefined @@ -2931,7 +2930,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail before step" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail before step" }), resume: false }) requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] @@ -2950,7 +2949,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail after output" }), resume: false }) requests.length = 0 response = [ @@ -2979,7 +2978,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail raw stream durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail raw stream durably" }), resume: false }) const failure = providerUnavailable() responseStream = Stream.fail(failure) @@ -2998,7 +2997,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Do not continue failed provider" }), + prompt: Prompt.make({ text: "Do not continue failed provider" }), resume: false, }) @@ -3021,7 +3020,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool durably" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool durably" }), resume: false }) requests.length = 0 response = [ @@ -3052,7 +3051,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail hosted tool at EOF" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Fail hosted tool at EOF" }), resume: false }) response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -3079,7 +3078,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Fail hosted tool on raw failure" }), + prompt: Prompt.make({ text: "Fail hosted tool on raw failure" }), resume: false, }) const failure = providerUnavailable() @@ -3114,7 +3113,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Two blocks" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Two blocks" }), resume: false }) responses = undefined streamGate = undefined @@ -3177,7 +3176,7 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Call provider tool" }), resume: false }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call provider tool" }), resume: false }) responses = undefined streamGate = undefined diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 09d9ea3926..d576b24cd1 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -363,7 +363,7 @@ function renderImportedEffectFiles( ? `import { ${api} } from ${JSON.stringify(options.module)}` : `import { HttpApi, HttpApiClient${"endpoints" in options ? ", HttpApiGroup" : ""} } from "effect/unstable/httpapi"\nimport { ${projection.imports.join(", ")} } from ${JSON.stringify(options.module)}` const httpApiImport = projection === undefined ? 'import { HttpApiClient } from "effect/unstable/httpapi"\n' : "" - const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` + const client = `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect${usesStream ? ", Stream" : ""}, Schema } from "effect"\nimport { Sse } from "effect/unstable/encoding"\nimport { HttpClientError } from "effect/unstable/http"\n${httpApiImport}${imports}\nimport { ClientError } from "./client-error"\n\n${projection?.source ?? ""}type RawClient = HttpApiClient.ForApi\n\nconst mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) ? new ClientError({ cause: error }) : error\n\n${adapters.join("\n\n")}\n\nconst adaptClient = (raw: RawClient) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) => HttpApiClient.make(${api}, options).pipe(Effect.map(adaptClient))\n` return [ { path: "client-error.ts", @@ -736,7 +736,7 @@ export function write( output.files, (file) => Effect.tryPromise({ - try: () => format(file.content, { parser: "typescript", semi: false, printWidth: 120 }), + try: () => format(file.content, { filepath: file.path, parser: "typescript", semi: false, printWidth: 120 }), catch: (error) => new GenerationError({ reason: `Failed to format ${file.path}: ${String(error)}` }), }).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))), { concurrency: 8, discard: true }, @@ -1143,5 +1143,5 @@ function renderClient(groups: ReadonlyArray) { const raw = `{ ${group.endpoints.map((item) => `${JSON.stringify(item.endpoint.name)}: raw[${JSON.stringify(item.endpoint.name)}]`).join(", ")} }` return [`...adaptGroup${index}(${raw})`] }) - return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map((raw) => ({ ${fields.join(", ")} })))\n` + return `// Generated by @opencode-ai/httpapi-codegen. Do not edit.\nimport { Effect } from "effect"\nimport { HttpApi, HttpApiClient } from "effect/unstable/httpapi"\n${imports}\n\nconst Api = ${api}\nconst adaptClient = (raw: HttpApiClient.ForApi) => ({ ${fields.join(", ")} })\n\nexport const make = (options?: { readonly baseUrl?: URL | string }) =>\n HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))\n` } diff --git a/packages/httpapi-codegen/test/generated/client.ts b/packages/httpapi-codegen/test/generated/client.ts index 0c3071823a..f67fc00a99 100644 --- a/packages/httpapi-codegen/test/generated/client.ts +++ b/packages/httpapi-codegen/test/generated/client.ts @@ -6,12 +6,11 @@ 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) => ({ + 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((raw) => ({ - session: adaptGroup0(raw["session"]), - event: adaptGroup1(raw["event"]), - ...adaptGroup2({ status: raw["status"] }), - })), - ) + HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) diff --git a/packages/model/src/prompt.ts b/packages/model/src/prompt.ts deleted file mode 100644 index a53cba7f04..0000000000 --- a/packages/model/src/prompt.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Schema } from "effect" - -export class Source extends Schema.Class("Prompt.Source")({ - start: Schema.Finite, - end: Schema.Finite, - text: Schema.String, -}) {} - -export class FileAttachment extends Schema.Class("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("Prompt.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), -}) {} - -export class Prompt extends Schema.Class("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) { - return new Prompt({ - text: input.text, - ...(input.files === undefined ? {} : { files: input.files }), - ...(input.agents === undefined ? {} : { agents: input.agents }), - }) - } -} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b74df8deb8..f494181cab 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -54,6 +54,7 @@ import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { EventV2 } from "@opencode-ai/core/event" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" import { ProjectV2 } from "@opencode-ai/core/project" @@ -280,6 +281,7 @@ export function createRoutes( HttpServer.layerServices, ]), Layer.provide(LayerNode.buildLayer(app)), + Layer.provide(ApplicationTools.layer), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 92d371077f..af2ded7488 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1086,12 +1086,12 @@ export const layer = Layer.effect( } if (part.type === "file") { result.files.push( - new FileAttachment({ + FileAttachment.make({ uri: part.url, mime: part.mime, name: part.filename, source: part.source - ? new Source({ + ? Source.make({ start: part.source.text.start, end: part.source.text.end, text: part.source.text.value, @@ -1102,10 +1102,10 @@ export const layer = Layer.effect( } if (part.type === "agent") { result.agents.push( - new AgentAttachment({ + AgentAttachment.make({ name: part.name, source: part.source - ? new Source({ + ? Source.make({ start: part.source.start, end: part.source.end, text: part.source.value, @@ -1130,7 +1130,7 @@ export const layer = Layer.effect( messageID: SessionMessage.ID.create(), timestamp: DateTime.makeUnsafe(info.time.created), delivery: "steer", - prompt: new Prompt({ + prompt: Prompt.make({ text: nextPrompt.text.join("\n"), files: nextPrompt.files, agents: nextPrompt.agents, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9a0679f4fc..ae72bc4d0a 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -23,7 +23,6 @@ import * as HttpSessionError from "../../src/server/routes/instance/httpapi/hand import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema" -import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -129,7 +128,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) => Effect.gen(function* () { - const message = new SessionMessage.Assistant({ + const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", agent: "build", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index a57544e870..34d2edb265 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -14,7 +14,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@opencode-ai/model": "workspace:*", + "@opencode-ai/schema": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/protocol/src/session-cursor.ts b/packages/protocol/src/session-cursor.ts index 0f7c9bde26..36aba1d489 100644 --- a/packages/protocol/src/session-cursor.ts +++ b/packages/protocol/src/session-cursor.ts @@ -1,7 +1,7 @@ -import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/model/schema" -import { Project } from "@opencode-ai/model/project" -import { Session } from "@opencode-ai/model/session" -import { Workspace } from "@opencode-ai/model/workspace" +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 = { diff --git a/packages/protocol/src/session.ts b/packages/protocol/src/session.ts index a7778f5b00..1f11c74406 100644 --- a/packages/protocol/src/session.ts +++ b/packages/protocol/src/session.ts @@ -1,13 +1,13 @@ -import { Agent } from "@opencode-ai/model/agent" -import { Location } from "@opencode-ai/model/location" -import { Model } from "@opencode-ai/model/model" -import { Project } from "@opencode-ai/model/project" -import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/model/schema" -import { Session } from "@opencode-ai/model/session" -import { SessionInput } from "@opencode-ai/model/session-input" -import { SessionMessage } from "@opencode-ai/model/session-message" -import { Prompt } from "@opencode-ai/model/prompt" -import { Workspace } from "@opencode-ai/model/workspace" +import { Agent } from "@opencode-ai/schema/agent" +import { Location } from "@opencode-ai/schema/location" +import { Model } from "@opencode-ai/schema/model" +import { Project } from "@opencode-ai/schema/project" +import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema" +import { Session } from "@opencode-ai/schema/session" +import { SessionInput } from "@opencode-ai/schema/session-input" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Prompt } from "@opencode-ai/schema/prompt" +import { Workspace } from "@opencode-ai/schema/workspace" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { diff --git a/packages/model/package.json b/packages/schema/package.json similarity index 93% rename from packages/model/package.json rename to packages/schema/package.json index d0e3f200e5..1b69f6f410 100644 --- a/packages/model/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "@opencode-ai/model", + "name": "@opencode-ai/schema", "private": true, "type": "module", "license": "MIT", diff --git a/packages/model/src/agent.ts b/packages/schema/src/agent.ts similarity index 100% rename from packages/model/src/agent.ts rename to packages/schema/src/agent.ts diff --git a/packages/model/src/identifier.ts b/packages/schema/src/identifier.ts similarity index 100% rename from packages/model/src/identifier.ts rename to packages/schema/src/identifier.ts diff --git a/packages/model/src/index.ts b/packages/schema/src/index.ts similarity index 100% rename from packages/model/src/index.ts rename to packages/schema/src/index.ts diff --git a/packages/model/src/location.ts b/packages/schema/src/location.ts similarity index 67% rename from packages/model/src/location.ts rename to packages/schema/src/location.ts index f0005d8586..8e702b2f7c 100644 --- a/packages/model/src/location.ts +++ b/packages/schema/src/location.ts @@ -4,7 +4,8 @@ import { Effect, Schema } from "effect" import { AbsolutePath } from "./schema" import { Workspace } from "./workspace" -export class Ref extends Schema.Class("Location.Ref")({ +export interface Ref extends Schema.Schema.Type {} +export const Ref = Schema.Struct({ directory: AbsolutePath, workspaceID: Schema.optional(Workspace.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))), -}) {} +}).annotate({ identifier: "Location.Ref" }) diff --git a/packages/model/src/model.ts b/packages/schema/src/model.ts similarity index 100% rename from packages/model/src/model.ts rename to packages/schema/src/model.ts diff --git a/packages/model/src/project.ts b/packages/schema/src/project.ts similarity index 100% rename from packages/model/src/project.ts rename to packages/schema/src/project.ts diff --git a/packages/schema/src/prompt.ts b/packages/schema/src/prompt.ts new file mode 100644 index 0000000000..7ee76f3906 --- /dev/null +++ b/packages/schema/src/prompt.ts @@ -0,0 +1,30 @@ +import { Schema } from "effect" + +export interface Source extends Schema.Schema.Type {} +export const Source = Schema.Struct({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}).annotate({ identifier: "Prompt.Source" }) + +export interface FileAttachment extends Schema.Schema.Type {} +export const FileAttachment = Schema.Struct({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}).annotate({ identifier: "Prompt.FileAttachment" }) + +export interface AgentAttachment extends Schema.Schema.Type {} +export const AgentAttachment = Schema.Struct({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}).annotate({ identifier: "Prompt.AgentAttachment" }) + +export interface Prompt extends Schema.Schema.Type {} +export const Prompt = Schema.Struct({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}).annotate({ identifier: "Prompt" }) diff --git a/packages/model/src/provider.ts b/packages/schema/src/provider.ts similarity index 100% rename from packages/model/src/provider.ts rename to packages/schema/src/provider.ts diff --git a/packages/model/src/schema.ts b/packages/schema/src/schema.ts similarity index 77% rename from packages/model/src/schema.ts rename to packages/schema/src/schema.ts index 405fd9b606..467eaae324 100644 --- a/packages/model/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -1,4 +1,6 @@ import { DateTime, Option, Schema, SchemaGetter } from "effect" +import { sha256 } from "@noble/hashes/sha2.js" +import { bytesToHex } from "@noble/hashes/utils.js" export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) @@ -22,6 +24,14 @@ export const withStatics = (schema: S): S & M => Object.assign(schema, methods(schema)) +export interface ExternalID { + readonly namespace: string + readonly key: string +} + +export const externalID = (prefix: string, input: ExternalID) => + `${prefix}_${bytesToHex(sha256(new TextEncoder().encode(JSON.stringify([input.namespace, input.key]))))}` + export const DateTimeUtcFromMillis = Schema.Finite.pipe( Schema.decodeTo(Schema.DateTimeUtc, { decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), diff --git a/packages/model/src/session-input.ts b/packages/schema/src/session-input.ts similarity index 77% rename from packages/model/src/session-input.ts rename to packages/schema/src/session-input.ts index 0d426ec414..17576ac1d3 100644 --- a/packages/model/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -9,7 +9,8 @@ import { SessionMessage } from "./session-message" export const Delivery = Schema.Literals(["steer", "queue"]) export type Delivery = typeof Delivery.Type -export class Admitted extends Schema.Class("SessionInput.Admitted")({ +export interface Admitted extends Schema.Schema.Type {} +export const Admitted = Schema.Struct({ admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: Session.ID, @@ -17,4 +18,4 @@ export class Admitted extends Schema.Class("SessionInput.Admitted")({ delivery: Delivery, timeCreated: DateTimeUtcFromMillis, promotedSeq: NonNegativeInt.pipe(Schema.optional), -}) {} +}).annotate({ identifier: "SessionInput.Admitted" }) diff --git a/packages/model/src/session-message.ts b/packages/schema/src/session-message.ts similarity index 62% rename from packages/model/src/session-message.ts rename to packages/schema/src/session-message.ts index 6bb64fa312..a8a94781c4 100644 --- a/packages/model/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -36,41 +36,47 @@ const Base = { time: Schema.Struct({ created: DateTimeUtcFromMillis }), } -export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ +export interface AgentSwitched extends Schema.Schema.Type {} +export const AgentSwitched = Schema.Struct({ ...Base, type: Schema.Literal("agent-switched"), agent: Schema.String, -}) {} +}).annotate({ identifier: "Session.Message.AgentSwitched" }) -export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ +export interface ModelSwitched extends Schema.Schema.Type {} +export const ModelSwitched = Schema.Struct({ ...Base, type: Schema.Literal("model-switched"), model: Model.Ref, -}) {} +}).annotate({ identifier: "Session.Message.ModelSwitched" }) -export class User extends Schema.Class("Session.Message.User")({ +export interface User extends Schema.Schema.Type {} +export const User = Schema.Struct({ ...Base, text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, type: Schema.Literal("user"), time: Schema.Struct({ created: DateTimeUtcFromMillis }), -}) {} +}).annotate({ identifier: "Session.Message.User" }) -export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ +export interface Synthetic extends Schema.Schema.Type {} +export const Synthetic = Schema.Struct({ ...Base, sessionID: Session.ID, text: Schema.String, type: Schema.Literal("synthetic"), -}) {} +}).annotate({ identifier: "Session.Message.Synthetic" }) -export class System extends Schema.Class("Session.Message.System")({ +export interface System extends Schema.Schema.Type {} +export const System = Schema.Struct({ ...Base, type: Schema.Literal("system"), text: Schema.String, -}) {} +}).annotate({ identifier: "Session.Message.System" }) -export class Shell extends Schema.Class("Session.Message.Shell")({ +export interface Shell extends Schema.Schema.Type {} +export const Shell = Schema.Struct({ ...Base, type: Schema.Literal("shell"), callID: Schema.String, @@ -80,21 +86,24 @@ export class Shell extends Schema.Class("Session.Message.Shell")({ created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(Schema.optional), }), -}) {} +}).annotate({ identifier: "Session.Message.Shell" }) -export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ +export interface ToolStatePending extends Schema.Schema.Type {} +export const ToolStatePending = Schema.Struct({ status: Schema.Literal("pending"), input: Schema.String, -}) {} +}).annotate({ identifier: "Session.Message.ToolState.Pending" }) -export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ +export interface ToolStateRunning extends Schema.Schema.Type {} +export const ToolStateRunning = Schema.Struct({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), structured: Schema.Record(Schema.String, Schema.Any), content: ToolContent.pipe(Schema.Array), -}) {} +}).annotate({ identifier: "Session.Message.ToolState.Running" }) -export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ +export interface ToolStateCompleted extends Schema.Schema.Type {} +export const ToolStateCompleted = Schema.Struct({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), attachments: FileAttachment.pipe(Schema.Array, Schema.optional), @@ -102,23 +111,25 @@ export class ToolStateCompleted extends Schema.Class("Sessio outputPaths: Schema.Array(Schema.String).pipe(Schema.optional), structured: Schema.Record(Schema.String, Schema.Any), result: Schema.Unknown.pipe(Schema.optional), -}) {} +}).annotate({ identifier: "Session.Message.ToolState.Completed" }) -export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ +export interface ToolStateError extends Schema.Schema.Type {} +export const ToolStateError = Schema.Struct({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), structured: Schema.Record(Schema.String, Schema.Any), error: UnknownError, result: Schema.Unknown.pipe(Schema.optional), -}) {} +}).annotate({ identifier: "Session.Message.ToolState.Error" }) export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( Schema.toTaggedUnion("status"), ) -export type ToolState = typeof ToolState.Type +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError -export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ +export interface AssistantTool extends Schema.Schema.Type {} +export const AssistantTool = Schema.Struct({ type: Schema.Literal("tool"), id: Schema.String, name: Schema.String, @@ -134,27 +145,30 @@ export class AssistantTool extends Schema.Class("Session.Message. completed: DateTimeUtcFromMillis.pipe(Schema.optional), pruned: DateTimeUtcFromMillis.pipe(Schema.optional), }), -}) {} +}).annotate({ identifier: "Session.Message.Assistant.Tool" }) -export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ +export interface AssistantText extends Schema.Schema.Type {} +export const AssistantText = Schema.Struct({ type: Schema.Literal("text"), id: Schema.String, text: Schema.String, -}) {} +}).annotate({ identifier: "Session.Message.Assistant.Text" }) -export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ +export interface AssistantReasoning extends Schema.Schema.Type {} +export const AssistantReasoning = Schema.Struct({ type: Schema.Literal("reasoning"), id: Schema.String, text: Schema.String, providerMetadata: ProviderMetadata.pipe(Schema.optional), -}) {} +}).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( Schema.toTaggedUnion("type"), ) -export type AssistantContent = typeof AssistantContent.Type +export type AssistantContent = AssistantText | AssistantReasoning | AssistantTool -export class Assistant extends Schema.Class("Session.Message.Assistant")({ +export interface Assistant extends Schema.Schema.Type {} +export const Assistant = Schema.Struct({ ...Base, type: Schema.Literal("assistant"), agent: Schema.String, @@ -177,15 +191,16 @@ export class Assistant extends Schema.Class("Session.Message.Assistan created: DateTimeUtcFromMillis, completed: DateTimeUtcFromMillis.pipe(Schema.optional), }), -}) {} +}).annotate({ identifier: "Session.Message.Assistant" }) -export class Compaction extends Schema.Class("Session.Message.Compaction")({ +export interface Compaction extends Schema.Schema.Type {} +export const Compaction = Schema.Struct({ type: Schema.Literal("compaction"), reason: Schema.Literals(["auto", "manual"]), summary: Schema.String, recent: Schema.String, ...Base, -}) {} +}).annotate({ identifier: "Session.Message.Compaction" }) export const Message = Schema.Union([ AgentSwitched, @@ -199,5 +214,5 @@ export const Message = Schema.Union([ ]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) -export type Message = typeof Message.Type +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction export type Type = Message["type"] diff --git a/packages/model/src/session.ts b/packages/schema/src/session.ts similarity index 73% rename from packages/model/src/session.ts rename to packages/schema/src/session.ts index d66094f5ea..25a1a44315 100644 --- a/packages/model/src/session.ts +++ b/packages/schema/src/session.ts @@ -1,21 +1,14 @@ export * as Session from "./session" import { Schema } from "effect" -import { sha256 } from "@noble/hashes/sha2.js" -import { bytesToHex } from "@noble/hashes/utils.js" import { Agent } from "./agent" import { Location } from "./location" import { Model } from "./model" import { Project } from "./project" -import { DateTimeUtcFromMillis, optionalOmitUndefined, RelativePath } from "./schema" +import { DateTimeUtcFromMillis, externalID, type ExternalID, optionalOmitUndefined, RelativePath } from "./schema" import { withStatics } from "./schema" import { descending } from "./identifier" -export interface ExternalID { - readonly namespace: string - readonly key: string -} - export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((schema) => { @@ -23,16 +16,14 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( return { create, descending: (id?: string) => (id === undefined ? create() : schema.make(id)), - fromExternal: (input: ExternalID) => - schema.make( - "ses_" + bytesToHex(sha256(new TextEncoder().encode(JSON.stringify([input.namespace, input.key])))), - ), + fromExternal: (input: ExternalID) => schema.make(externalID("ses", input)), } }), ) export type ID = typeof ID.Type -export class Info extends Schema.Class("SessionV2.Info")({ +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optionalOmitUndefined), projectID: Project.ID, @@ -56,7 +47,7 @@ export class Info extends Schema.Class("SessionV2.Info")({ title: Schema.String, location: Location.Ref, subpath: RelativePath.pipe(Schema.optional), -}) {} +}).annotate({ identifier: "SessionV2.Info" }) export const ListAnchor = Schema.Struct({ id: ID, diff --git a/packages/model/src/workspace.ts b/packages/schema/src/workspace.ts similarity index 100% rename from packages/model/src/workspace.ts rename to packages/schema/src/workspace.ts diff --git a/packages/model/tsconfig.json b/packages/schema/tsconfig.json similarity index 100% rename from packages/model/tsconfig.json rename to packages/schema/tsconfig.json diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md new file mode 100644 index 0000000000..449bd8f6da --- /dev/null +++ b/packages/sdk-next/README.md @@ -0,0 +1,27 @@ +# @opencode-ai/sdk-next + +Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate. + +The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client. + +```ts +import { OpenCode } from "@opencode-ai/sdk-next" + +const opencode = yield * OpenCode.create() +const session = yield * opencode.sessions.get({ sessionID }) +``` + +It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations. + +The same constructor is available as a service Layer: + +```ts +const program = Effect.gen(function* () { + const opencode = yield* OpenCode.Service + return yield* opencode.sessions.get({ sessionID }) +}) + +yield * program.pipe(Effect.provide(OpenCode.layer)) +``` + +`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation. diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json new file mode 100644 index 0000000000..55a891a10f --- /dev/null +++ b/packages/sdk-next/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/sdk-next", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "bun test --timeout 5000", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/client": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts new file mode 100644 index 0000000000..f33f7a846c --- /dev/null +++ b/packages/sdk-next/src/index.ts @@ -0,0 +1,16 @@ +export * as OpenCode from "./opencode" + +export { ClientError } from "@opencode-ai/client/effect" +export { Tool } from "@opencode-ai/core/public/tool" +export { + AbsolutePath, + Agent, + Location, + Model, + Prompt, + Provider, + RelativePath, + Session, + SessionInput, + SessionMessage, +} from "@opencode-ai/client/effect" diff --git a/packages/client/src/effect-embedded.ts b/packages/sdk-next/src/opencode.ts similarity index 60% rename from packages/client/src/effect-embedded.ts rename to packages/sdk-next/src/opencode.ts index 5da275d3b3..5db24a7144 100644 --- a/packages/client/src/effect-embedded.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,5 +1,5 @@ -export * as OpenCode from "./effect-embedded" - +import { OpenCode } from "@opencode-ai/client/effect" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import { Cause, Context, Effect, Layer } from "effect" @@ -11,20 +11,19 @@ import { HttpServerRequest, HttpServerResponse, } from "effect/unstable/http" -import { OpenCode as Generated } from "./generated-effect/index" export const create = Effect.fn("OpenCode.create")(function* () { - const context = yield* Layer.build( - Layer.merge( - createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(HttpRouter.layer)), - ApplicationTools.layer, - ), - ) - const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + const { handler, permissions, tools } = yield* Effect.all({ + handler: HttpRouter.toHttpEffect(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices))), + permissions: PermissionSaved.Service, + tools: ApplicationTools.Service, + }).pipe(Effect.provide(Layer.merge(ApplicationTools.layer, PermissionSaved.defaultLayer))) const httpClient = HttpClient.make( Effect.fnUntraced(function* (request) { const response = yield* handler.pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)), + Effect.provideService(ApplicationTools.Service, tools), + Effect.provideService(PermissionSaved.Service, permissions), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.interrupt @@ -34,10 +33,9 @@ export const create = Effect.fn("OpenCode.create")(function* () { return HttpServerResponse.toClientResponse(response, { request }) }, Effect.scoped), ) - const client = yield* Generated.make({ baseUrl: "http://opencode.local" }).pipe( + const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), ) - const tools = Context.get(context, ApplicationTools.Service) return { ...client, tools: { register: tools.register }, @@ -46,20 +44,6 @@ export const create = Effect.fn("OpenCode.create")(function* () { export type Interface = Effect.Success> -export class Service extends Context.Service()("@opencode-ai/client/OpenCode") {} +export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} export const layer = Layer.effect(Service, create()) - -export { ClientError } from "./generated-effect/index" -export { Tool } from "@opencode-ai/core/public/tool" -export { - Agent, - Location, - Model, - AbsolutePath, - RelativePath, - Session, - SessionInput, - SessionMessage, - Prompt, -} from "./effect" diff --git a/packages/client/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts similarity index 93% rename from packages/client/test/embedded.test.ts rename to packages/sdk-next/test/embedded.test.ts index d1480fd37d..e163d12c37 100644 --- a/packages/client/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -9,11 +9,9 @@ test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) const database = Flag.OPENCODE_DB Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, Tool } = await import( - "../src/effect-embedded" - ) + const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) - const model = Model.Ref.make({ id: "embedded", providerID: "test" }) + const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) try { const program = Effect.gen(function* () { @@ -37,7 +35,7 @@ test("embedded client uses the real router and handlers", async () => { const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) const admitted = yield* opencode.sessions.prompt({ sessionID, - prompt: new Prompt({ text: "Do not run" }), + prompt: Prompt.make({ text: "Do not run" }), resume: false, }) const context = yield* opencode.sessions.context({ sessionID }) @@ -64,7 +62,7 @@ test("embedded client is available as a Layer service", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) const database = Flag.OPENCODE_DB Flag.OPENCODE_DB = join(directory, "opencode.sqlite") - const { AbsolutePath, Location, OpenCode, Session } = await import("../src/effect-embedded") + const { AbsolutePath, Location, OpenCode, Session } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) try { diff --git a/packages/sdk-next/test/import-boundaries.test.ts b/packages/sdk-next/test/import-boundaries.test.ts new file mode 100644 index 0000000000..f6a7d178e2 --- /dev/null +++ b/packages/sdk-next/test/import-boundaries.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join, resolve, sep } from "node:path" + +const directory = resolve(import.meta.dir, "..") +const client = resolve(import.meta.dir, "../../client") +const core = resolve(import.meta.dir, "../../core") +const server = resolve(import.meta.dir, "../../server") + +test("bundles the client and in-memory host", async () => { + const inputs = await bundleInputs() + + expect(within(inputs, client).length).toBeGreaterThan(0) + expect(within(inputs, core).length).toBeGreaterThan(0) + expect(within(inputs, server).length).toBeGreaterThan(0) +}) + +async function bundleInputs() { + const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-")) + const entrypoint = join(temporary, "index.ts") + const metafile = join(temporary, "meta.json") + try { + await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"') + const child = Bun.spawn( + [ + process.execPath, + "build", + entrypoint, + "--target=bun", + "--format=esm", + "--packages=bundle", + `--metafile=${metafile}`, + `--outdir=${join(temporary, "out")}`, + ], + { cwd: directory, stdout: "pipe", stderr: "pipe" }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (exitCode !== 0) throw new Error(stdout + stderr) + const metadata = await Bun.file(metafile).json() + return Object.keys(metadata.inputs).map((input) => resolve(directory, input)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +function within(inputs: ReadonlyArray, directory: string) { + const prefix = directory.endsWith(sep) ? directory : directory + sep + return inputs.filter((input) => input === directory || input.startsWith(prefix)) +} diff --git a/packages/sdk-next/tsconfig.json b/packages/sdk-next/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/sdk-next/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 541cfed633..01451cd529 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -53,6 +53,6 @@ export const handlers = Layer.mergeAll( Layer.provide(SessionExecutionLocal.defaultLayer), Layer.provide(PermissionSaved.defaultLayer), Layer.provide(PtyTicket.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(LocationServiceMap.layerWithApplicationTools), Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 2c2c8fc334..ada315fa12 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,5 +1,6 @@ import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" @@ -15,7 +16,7 @@ export function createRoutes(password?: string) { password ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.defaultLayer, - ) + ).pipe(Layer.provide(ApplicationTools.layer)) } export function createEmbeddedRoutes() {