refactor(core): move database schema ownership (#29068)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Dax
2026-05-30 21:08:38 -04:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent 6bcb9cb9bb
commit 7f571d36ea
390 changed files with 11127 additions and 9164 deletions
@@ -27,7 +27,7 @@ describe("session.listGlobal", () => {
const firstSession = yield* withSession({ title: "first-session" })
const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second))
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
@@ -56,12 +56,14 @@ describe("session.listGlobal", () => {
yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() }))
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })])
const allSessions = yield* SessionNs.Service.use((session) =>
session.listGlobal({ limit: 200, archived: true }),
)
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
@@ -86,13 +88,15 @@ describe("session.listGlobal", () => {
)
const second = yield* withSession({ title: "page-two" })
const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })])
const page = yield* SessionNs.Service.use((session) =>
session.listGlobal({ directory: test.directory, limit: 1 }),
)
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = yield* Effect.sync(() => [
...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
])
const next = yield* SessionNs.Service.use((session) =>
session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
)
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
@@ -1,279 +0,0 @@
// Diagnostic suite for /event SSE delivery.
//
// Each test isolates ONE variable in the publisher chain while keeping the
// subscriber path constant (in-process HttpApi via Server.Default reading the
// SSE body). The pass/fail pattern across tests tells us where the bug lives:
//
// D1 (baseline): publish via Bus.use.publish — mirror of httpapi-event.test.ts
// test 3. Confirms /event SSE delivery works for SOME publish path.
//
// D2: publish N times in quick succession via Bus.use.publish. If the bus
// subscription is acquired correctly there should be no message loss.
//
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
// handlers use (Session.updatePart → sync.run → bus.publish) without
// the HTTP roundtrip. Tells us whether the sync path itself can deliver
// in-process.
//
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
// callback. Confirms pub/sub identity end-to-end without /event SSE.
//
// D5: in-process Bus callback subscriber AND raw /event SSE subscriber
// receive the same publish. If both receive: no bug. If only the
// callback receives: the /event handler has an acquisition race.
//
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. Order-of-setup variable.
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Bus } from "../../src/bus"
import { Event as ServerEvent } from "../../src/server/event"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SyncEvent } from "../../src/sync"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffectShared } from "../lib/effect"
void Log.init({ print: false })
const SseEvent = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
type SseEvent = Schema.Schema.Type<typeof SseEvent>
type BusEvent = { type: string; properties: unknown }
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const it = testEffectShared(Layer.mergeAll(Bus.defaultLayer, SyncEvent.defaultLayer))
const publishConnected = Bus.use.publish(ServerEvent.Connected, {})
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
sessionID,
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
time: Date.now(),
})
}
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
Effect.acquireRelease(Bus.use.subscribeAllCallback(handler), (dispose) => Effect.sync(() => dispose()))
const openEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
)
if (!response.body) return yield* Effect.die("missing SSE response body")
const reader = response.body.getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
return reader
})
const decoder = new TextDecoder()
function decodeFrame(value: Uint8Array): SseEvent[] {
return decoder
.decode(value)
.split(/\n\n+/)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
}
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
Effect.promise(() => reader.read()).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
}),
Effect.flatMap((result) => {
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
const frames = decodeFrame(result.value)
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
return Effect.succeed(frames[0]!)
}),
)
const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
Effect.gen(function* () {
const events: SseEvent[] = []
while (true) {
const event = yield* readNextEvent(reader)
events.push(event)
if (predicate(event)) return events
}
}).pipe(
Effect.timeoutOrElse({
duration: "4 seconds",
orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
}),
)
const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
describe("/event SSE delivery diagnostics", () => {
// Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
// but explicit about timing — publish happens with NO wait after reading
// server.connected. If this fails we have a deeper problem than just sync.
it.instance(
"D1: delivers a single bus event published right after server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
yield* publishConnected
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
it.instance(
"D2: delivers all N bus events published in rapid succession",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const N = 5
yield* Effect.replicateEffect(publishConnected, N)
const received = yield* Effect.replicateEffect(readNextEvent(reader), N)
expect(received).toHaveLength(N)
for (const event of received) expect(event.type).toBe("server.connected")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// The critical test. If D1 passes but this fails, the bus-identity fix is
// incomplete OR the sync.run publish path doesn't reach the same bus
// /event subscribes to, even when both share the memoMap.
it.instance(
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const collected = yield* collectUntilEvent(reader, isPartUpdated)
const updated = collected.find(isPartUpdated)
expect(updated?.properties.part.id).toBe(partID)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
// bug is specifically in the cross-request / cross-fiber HTTP path, not in
// the publish itself. If D3 also fails, the publish chain is broken.
//
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
// pub/sub identity end-to-end without involving /event SSE.
it.instance(
"D4: SyncEvent.use.run publish reaches an in-process Bus callback",
() =>
Effect.gen(function* () {
const received = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(received, Effect.succeed(event))
})
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const event = yield* Deferred.await(received).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () => Effect.fail(new Error("D4 timed out waiting for callback")),
}),
)
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
expect(event.properties).toMatchObject({ part: { id: partID } })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
// SyncEvent.use.run. Both subscribers should receive it. If only one does
// we know exactly which side of the chain is failing.
it.instance(
"D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const callbackReceived = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
})
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
Effect.map((events) => events.some(isPartUpdated)),
Effect.catch(() => Effect.succeed(false)),
)
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
Effect.map((event) => event !== undefined),
)
// Single assert with the boolean pair so the failure message tells us
// exactly which side broke.
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
// subscription is established. If D5 fails and D6 passes, the order of
// subscriber setup is the determining factor.
it.instance(
"D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const reader = yield* openEventStream(directory)
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
const callbackReceived = yield* Deferred.make<BusEvent>()
yield* subscribeAllCallback((event) => {
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
})
const partID = PartID.ascending()
yield* publishPartUpdated(partID)
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
Effect.map((events) => events.some(isPartUpdated)),
Effect.catch(() => Effect.succeed(false)),
)
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
Effect.map((event) => event !== undefined),
)
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -1,13 +1,11 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Layer, Queue, Schema, Stream } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Bus } from "../../src/bus"
import { Event as ServerEvent } from "../../src/server/event"
import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffectShared } from "../lib/effect"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
@@ -17,28 +15,25 @@ const EventData = Schema.Struct({
properties: Schema.Record(Schema.String, Schema.Any),
})
const readEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
const readEvent = (reader: Queue.Dequeue<Uint8Array>) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() => reader.read()).pipe(
const value = yield* Queue.take(reader).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for event")),
}),
)
if (result.done || !result.value) return yield* Effect.fail(new Error("event stream closed"))
return Schema.decodeUnknownSync(EventData)(
JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")),
)
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, "")))
})
const openEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* Effect.promise(async () =>
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
const response = yield* requestInDirectory(EventPaths.event, directory)
const reader = yield* Queue.unbounded<Uint8Array>()
yield* response.stream.pipe(
Stream.runForEach((value) => Queue.offer(reader, value)),
Effect.forkScoped,
)
if (!response.body) return yield* Effect.die("missing SSE response body")
const reader = response.body.getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
return { response, reader }
})
@@ -47,7 +42,7 @@ afterEach(async () => {
await resetDatabase()
})
const it = testEffectShared(Bus.defaultLayer)
const it = testEffect(httpApiLayer)
describe("event HttpApi", () => {
it.instance(
@@ -58,10 +53,10 @@ describe("event HttpApi", () => {
const { response, reader } = yield* openEventStream(directory)
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/event-stream")
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
expect(response.headers.get("x-accel-buffering")).toBe("no")
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(response.headers["content-type"]).toContain("text/event-stream")
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
expect(response.headers["x-accel-buffering"]).toBe("no")
expect(response.headers["x-content-type-options"]).toBe("nosniff")
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
}),
{ git: true, config: { formatter: false, lsp: false } },
@@ -76,8 +71,8 @@ describe("event HttpApi", () => {
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
// If no second event arrives within 250ms, the stream is still open.
const status = yield* Effect.promise(() => reader.read()).pipe(
Effect.map((result) => (result.done ? ("closed" as const) : ("event" as const))),
const status = yield* Queue.take(reader).pipe(
Effect.as("event" as const),
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
)
expect(status).toBe("open")
@@ -86,16 +81,18 @@ describe("event HttpApi", () => {
)
it.instance(
"delivers instance bus events after the initial event",
"delivers instance events after the initial event",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { reader } = yield* openEventStream(directory)
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
yield* Bus.use.publish(ServerEvent.Connected, {})
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
const created = yield* requestInDirectory("/session", directory, { method: "POST" })
expect(created.status).toBe(200)
expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -56,7 +56,7 @@ function app(modules: Runtime, options: CallOptions) {
),
),
),
{ disableLogger: true },
{ disableLogger: true, memoMap: modules.memoMap },
).handler
return (appCache[cacheKey] = {
request(input: string | URL | Request, init?: RequestInit) {
@@ -1,14 +1,16 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Cause, Duration, Effect } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Duration, Effect, Layer, Scope } from "effect"
import { TestLLMServer } from "../../lib/llm-server"
import type { Config } from "../../../src/config/config"
import { ModelID, ProviderID } from "../../../src/provider/schema"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema"
import { call, callAuthProbe } from "./backend"
import { original } from "./environment"
import { runtime } from "./runtime"
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
import { ProviderV2 } from "@opencode-ai/core/provider"
export function runScenario(options: Options) {
return (scenario: Scenario) => {
@@ -85,18 +87,20 @@ function withContext<A, E>(
Effect.gen(function* () {
yield* trace(options, scenario, `${label} runtime start`)
const modules = yield* Effect.promise(() => runtime())
const scope = yield* Scope.Scope
const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
yield* trace(options, scenario, `${label} runtime done`)
const path = context.dir?.path
const instance = path
? yield* trace(options, scenario, `${label} instance load start`).pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
Effect.provide(app),
Effect.catchCause((cause) =>
Effect.sleep("100 millis").pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(modules.AppLayer),
Effect.provide(app),
),
),
Effect.catchCause(() => Effect.failCause(cause)),
@@ -108,7 +112,7 @@ function withContext<A, E>(
)
: undefined
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer))
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
const directory = () => {
if (!context.dir?.path) throw new Error("scenario needs a project directory")
return context.dir.path
@@ -140,18 +144,18 @@ function withContext<A, E>(
}),
message: (sessionID, input) =>
Effect.gen(function* () {
const info: MessageV2.User = {
const info: SessionLegacy.User = {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: "build",
model: {
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.opencode,
modelID: ProviderV2.ModelID.make("test"),
},
}
const part: MessageV2.TextPart = {
const part: SessionLegacy.TextPart = {
id: PartID.ascending(),
sessionID,
messageID: info.id,
@@ -2,6 +2,7 @@ export type Runtime = {
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
memoMap: (typeof import("@opencode-ai/core/effect/memo-map"))["memoMap"]
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"]
@@ -21,6 +22,7 @@ export function runtime() {
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const appRuntime = await import("../../../src/effect/app-runtime")
const memoMap = await import("@opencode-ai/core/effect/memo-map")
const instanceRef = await import("../../../src/effect/instance-ref")
const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session")
@@ -34,6 +36,7 @@ export function runtime() {
PublicApi: publicApi.PublicApi,
HttpApiApp: httpApiServer.HttpApiApp,
AppLayer: appRuntime.AppLayer,
memoMap: memoMap.memoMap,
InstanceRef: instanceRef.InstanceRef,
InstanceStore: instanceStore.InstanceStore,
Session: session.Session,
@@ -1,4 +1,5 @@
import type { Duration, Effect } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { Config } from "../../../src/config/config"
import type { Project } from "../../../src/project/project"
import type { Worktree } from "../../../src/worktree"
@@ -57,7 +58,7 @@ export type ScenarioContext = {
sessionGet: (sessionID: SessionID) => Effect.Effect<SessionInfo | undefined>
project: () => Effect.Effect<Project.Info>
message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect<MessageSeed>
messages: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts[]>
messages: (sessionID: SessionID) => Effect.Effect<SessionLegacy.WithParts[]>
todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect<void>
worktree: (input?: { name?: string }) => Effect.Effect<Worktree.Info>
worktreeRemove: (directory: string) => Effect.Effect<void>
@@ -118,4 +119,4 @@ export type Result =
export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID }
export type TodoInfo = { content: string; status: string; priority: string }
export type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart }
export type MessageSeed = { info: SessionLegacy.User; part: SessionLegacy.TextPart }
@@ -1,41 +1,36 @@
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { AccountV2 } from "@opencode-ai/core/account"
import { AccountTable } from "@opencode-ai/core/account/sql"
import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Session.defaultLayer))
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
function app() {
return Server.Default().app
}
function request(path: string, directory: string, init: RequestInit = {}) {
return Effect.promise(() => {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Promise.resolve(app().request(path, { ...init, headers }))
})
return requestInDirectory(path, directory, init)
}
function createSession(input?: Session.CreateInput) {
return Session.use.create(input)
}
function json<T>(response: Response) {
return Effect.promise(() => response.json() as Promise<T>)
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
function waitReady(input: { directory?: string; name?: string }) {
@@ -62,38 +57,50 @@ function waitReady(input: { directory?: string; name?: string }) {
function insertAccount() {
return Effect.acquireRelease(
Effect.sync(() => {
Database.Client()
.$client.prepare(
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
"account-test",
"test@example.com",
"https://console.example.com",
"access",
"refresh",
Date.now(),
Date.now(),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(AccountTable)
.values({
id: AccountV2.ID.make("account-test"),
email: "test@example.com",
url: "https://console.example.com",
access_token: AccountV2.AccessToken.make("access"),
refresh_token: AccountV2.RefreshToken.make("refresh"),
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
return "account-test"
}),
(id) =>
Effect.sync(() => {
Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id)
}),
Database.Service.use(({ db }) =>
db
.delete(AccountTable)
.where(eq(AccountTable.id, AccountV2.ID.make(id)))
.run()
.pipe(Effect.orDie),
),
)
}
function setSessionUpdated(session: Session.Info, updated: number) {
return Effect.sync(() => {
Database.use((db) =>
db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(),
)
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ time_updated: updated })
.where(eq(SessionTable.id, session.id))
.run()
.pipe(Effect.orDie)
})
}
function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect<void, unknown, never>) {
function withCreatedWorktree(
directory: string,
use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
) {
const name = "api-test"
const headers = { "content-type": "application/json" }
return Effect.acquireUseRelease(
@@ -242,7 +249,7 @@ describe("experimental HttpApi", () => {
tmp.directory,
)
expect(page.status).toBe(200)
expect(page.headers.get("x-next-cursor")).toBeTruthy()
expect(page.headers["x-next-cursor"]).toBeTruthy()
const body = yield* json<Session.GlobalInfo[]>(page)
expect(body.map((session) => session.id)).toEqual([second.id])
@@ -7,7 +7,7 @@ import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
@@ -236,7 +236,7 @@ describe("HttpApi instance context middleware", () => {
it.live("uses configured workspace id instead of routing to the requested workspace", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -264,7 +264,7 @@ describe("HttpApi instance context middleware", () => {
it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -276,7 +276,7 @@ describe("HttpApi instance context middleware", () => {
// MissingWorkspace response. With the env set, planRequest must skip the
// MissingWorkspace branch and fall through to Local with the configured
// workspace id.
const unknownWorkspaceID = WorkspaceID.ascending()
const unknownWorkspaceID = WorkspaceV2.ID.ascending()
const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
HttpClient.execute,
@@ -292,7 +292,7 @@ describe("HttpApi instance context middleware", () => {
it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceID.ascending()
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
@@ -4,12 +4,12 @@ import { describe, expect } from "bun:test"
import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema"
import { ProjectID } from "../../src/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
@@ -17,7 +17,7 @@ import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
// Flip the experimental workspaces flag so SyncEvent.run actually writes to
// Flip the experimental workspaces flag so EventV2.run actually writes to
// EventSequenceTable (the source of truth the fence middleware reads). Reset
// the database around the test so per-instance state does not leak between
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
@@ -76,7 +76,7 @@ describe("instance HttpApi", () => {
it.live("emits a sync fence header for fixed-workspace mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
@@ -98,7 +98,7 @@ describe("instance HttpApi", () => {
it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
@@ -209,7 +209,7 @@ describe("instance HttpApi", () => {
it.live("returns typed not found bodies for missing projects", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const projectID = ProjectID.make("project_missing")
const projectID = ProjectV2.ID.make("project_missing")
const response = yield* Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost/project/${projectID}`, {
@@ -0,0 +1,33 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Config, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{
disableListenLog: true,
disableLogger: true,
},
)
export const httpApiLayer = servedRoutes.pipe(
Layer.provide(layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
export function request(path: string, init?: RequestInit) {
const url = new URL(path, "http://localhost")
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
HttpClientRequest.setUrl(url.pathname),
HttpClient.execute,
)
}
export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return request(path, { ...init, headers })
}
@@ -2,12 +2,12 @@ import { describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import path from "path"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { testEffect } from "../lib/effect"
import { httpApiLayer, request } from "./httpapi-layer"
void Log.init({ print: false })
@@ -18,16 +18,12 @@ const testStateLayer = Layer.effectDiscard(
),
)
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer))
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer))
const projectOptions = { config: { formatter: false, lsp: false } }
const providerID = "test-oauth-parity"
const oauthURL = "https://example.com/oauth"
const oauthInstructions = "Finish OAuth"
function app() {
return Server.Default().app
}
function providerListHasFetch(list: unknown) {
if (!Array.isArray(list)) return false
return list.some((item: unknown) => {
@@ -77,41 +73,34 @@ function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id:
}
function requestAuthorize(input: {
app: ReturnType<typeof app>
providerID: string
method: number
headers: HeadersInit
inputs?: Record<string, string>
}) {
return Effect.promise(async () => {
const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
return Effect.gen(function* () {
const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
method: "POST",
headers: input.headers,
body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
})
return {
status: response.status,
body: await response.text(),
body: yield* response.text,
}
})
}
function requestCallback(input: {
app: ReturnType<typeof app>
providerID: string
method: number
headers: HeadersInit
code?: string
}) {
return Effect.promise(async () => {
const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, {
function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
return Effect.gen(function* () {
const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
method: "POST",
headers: input.headers,
body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
})
return {
status: response.status,
body: await response.text(),
body: yield* response.text,
}
})
}
@@ -277,15 +266,13 @@ describe("provider HttpApi", () => {
it.instance.skip(
"returns public v2 provider not found errors",
Effect.gen(function* () {
const instance = yield* TestInstance
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }),
),
)
const directory = (yield* TestInstance).directory
const response = yield* request("/api/provider/missing", {
headers: { "x-opencode-directory": directory },
})
expect(response.status).toBe(404)
expect(yield* Effect.promise(() => response.json())).toEqual({
expect(yield* response.json).toEqual({
_tag: "ProviderNotFoundError",
providerID: "missing",
message: "Provider not found: missing",
@@ -297,13 +284,9 @@ describe("provider HttpApi", () => {
it.instance(
"serves OAuth authorize response shapes",
Effect.gen(function* () {
const instance = yield* TestInstance
yield* writeProviderAuthPlugin(instance.directory)
const headers = { "x-opencode-directory": instance.directory, "content-type": "application/json" }
const server = app()
const directory = (yield* TestInstance).directory
const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
const api = yield* requestAuthorize({
app: server,
providerID,
method: 0,
headers,
@@ -315,7 +298,6 @@ describe("provider HttpApi", () => {
expect(api).toEqual({ status: 200, body: "null" })
const oauth = yield* requestAuthorize({
app: server,
providerID,
method: 1,
headers,
@@ -326,21 +308,19 @@ describe("provider HttpApi", () => {
instructions: oauthInstructions,
})
}),
projectOptions,
{ ...projectOptions, init: writeProviderAuthPlugin },
30000,
)
it.instance(
"returns declared provider auth validation errors",
Effect.gen(function* () {
const instance = yield* TestInstance
yield* writeProviderAuthValidationPlugin(instance.directory)
const directory = (yield* TestInstance).directory
const response = yield* requestAuthorize({
app: app(),
providerID: "test-oauth-validation",
method: 0,
inputs: { token: "nope" },
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
})
expect(response.status).toBe(400)
@@ -349,19 +329,18 @@ describe("provider HttpApi", () => {
data: { field: "token", message: "Token must be ok" },
})
}),
projectOptions,
{ ...projectOptions, init: writeProviderAuthValidationPlugin },
30000,
)
it.instance(
"returns declared provider auth callback errors",
Effect.gen(function* () {
const instance = yield* TestInstance
const directory = (yield* TestInstance).directory
const response = yield* requestCallback({
app: app(),
providerID,
method: 0,
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
})
expect(response.status).toBe(400)
@@ -377,54 +356,48 @@ describe("provider HttpApi", () => {
it.instance(
"serves provider lists when auth loaders add runtime fetch options",
Effect.gen(function* () {
const instance = yield* TestInstance
yield* writeFunctionOptionsPlugin(instance.directory)
const directory = (yield* TestInstance).directory
yield* setEnvScoped(
"OPENCODE_AUTH_CONTENT",
JSON.stringify({
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
}),
)
const headers = { "x-opencode-directory": instance.directory }
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
const configResponse = yield* Effect.promise(() =>
Promise.resolve(app().request("/config/providers", { headers })),
)
const headers = { "x-opencode-directory": directory }
const providerResponse = yield* request("/provider", { headers })
const configResponse = yield* request("/config/providers", { headers })
expect(providerResponse.status).toBe(200)
expect(configResponse.status).toBe(200)
const providerBody = yield* Effect.promise(() => providerResponse.json())
const configBody = yield* Effect.promise(() => configResponse.json())
const providerBody = yield* providerResponse.json
const configBody = yield* configResponse.json
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
}),
projectOptions,
{ ...projectOptions, init: writeFunctionOptionsPlugin },
)
it.instance(
"keeps provider.models hook input mutations out of provider state",
Effect.gen(function* () {
const instance = yield* TestInstance
yield* writeProviderModelsMutationPlugin(instance.directory)
const directory = (yield* TestInstance).directory
const headers = { "x-opencode-directory": instance.directory }
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
const configResponse = yield* Effect.promise(() =>
Promise.resolve(app().request("/config/providers", { headers })),
)
const headers = { "x-opencode-directory": directory }
const providerResponse = yield* request("/provider", { headers })
const configResponse = yield* request("/config/providers", { headers })
expect(providerResponse.status).toBe(200)
expect(configResponse.status).toBe(200)
const providerBody = yield* Effect.promise(() => providerResponse.json())
const configBody = yield* Effect.promise(() => configResponse.json())
const providerBody = yield* providerResponse.json
const configBody = yield* configResponse.json
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
}),
projectOptions,
{ ...projectOptions, init: writeProviderModelsMutationPlugin },
)
})
@@ -1,19 +1,23 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Server } from "../../src/server/server"
import { Database } from "@opencode-ai/core/database/database"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql"
import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Session.defaultLayer)
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
const text = (response: HttpClientResponse.HttpClientResponse) => response.text
afterEach(async () => {
await disposeAllInstances()
@@ -28,7 +32,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
@@ -43,22 +47,20 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
})
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
// that broke the user's session in the OMO/Windows bug.
yield* Effect.sync(() =>
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
),
)
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
@@ -68,16 +70,14 @@ describe("schema-rejection wire shape", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
)
const body = yield* Effect.promise(async () => res.text())
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
})
const body = yield* text(res)
expect(res.status).toBe(400)
expect(res.headers.get("content-type") ?? "").toContain("application/json")
expect(res.headers["content-type"] ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({
name: "BadRequest",
@@ -96,8 +96,8 @@ describe("schema-rejection wire shape", () => {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check.
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
const res = yield* requestInDirectory(url, test.directory)
const body = yield* text(res)
expect(res.status).toBe(400)
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
@@ -110,12 +110,8 @@ describe("schema-rejection wire shape", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () =>
Server.Default().app.request("/api/session?limit=0", {
headers: { "x-opencode-directory": test.directory },
}),
)
const parsed = JSON.parse(yield* Effect.promise(async () => res.text()))
const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
const parsed = JSON.parse(yield* text(res))
expect(res.status).toBe(400)
expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
expect(parsed.message).toEqual(expect.any(String))
@@ -132,14 +128,12 @@ describe("schema-rejection wire shape", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
}),
)
const body = yield* Effect.promise(async () => res.text())
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
})
const body = yield* text(res)
expect(res.status).toBe(400)
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
expect(body.length).toBeLessThan(2 * 1024)
@@ -156,10 +150,10 @@ describe("schema-rejection wire shape", () => {
const test = yield* TestInstance
const sessionID = yield* seedCorruptStepFinishPart
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
const res = yield* requestInDirectory(url, test.directory)
const body = yield* text(res)
expect(res.status).toBe(400)
expect(res.headers.get("content-type") ?? "").toContain("application/json")
expect(res.headers["content-type"] ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
// Field path in data.message — what made this PR worth shipping.
@@ -1,7 +1,8 @@
import { afterEach, describe, expect } from "bun:test"
import { ConfigProvider, Deferred, Effect, Layer } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Deferred, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { HttpServer } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -10,11 +11,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Config } from "@/config/config"
import { Session as SessionNs } from "@/session/session"
import { errorMessage } from "../../src/util/error"
@@ -24,6 +23,9 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Database } from "@opencode-ai/core/database/database"
import { httpApiLayer } from "./httpapi-layer"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
@@ -31,6 +33,8 @@ const it = testEffect(
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
Database.defaultLayer,
httpApiLayer,
),
)
@@ -45,55 +49,47 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown }
type Captured = { status: number; data?: unknown; error?: unknown }
type ProjectFixture = { sdk: Sdk; directory: string }
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
type TestServices =
| AppFileSystem.Service
| ChildProcessSpawner.ChildProcessSpawner
| InstanceStore.Service
| HttpServer.HttpServer
type TestScope = Scope.Scope | TestServices
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
if (serverPath === "default") return Server.Default().app
const handler = HttpRouter.toWebHandler(
HttpApiApp.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, HttpApiApp.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function client(
serverPath: ServerPath,
directory?: string,
input?: { password?: string; username?: string; headers?: Record<string, string> },
) {
return createOpencodeClient({
baseUrl: "http://localhost",
directory,
headers: input?.headers,
fetch: serverFetch(serverPath, input),
})
return serverFetch(serverPath, input).pipe(
Effect.map((fetch) =>
createOpencodeClient({
baseUrl: "http://localhost",
directory,
headers: input?.headers,
fetch,
}),
),
)
}
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
const serverApp = app(serverPath, input)
return Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) =>
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
return HttpServer.HttpServer.use((server) =>
Effect.sync(() => {
void serverPath
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
const baseUrl = HttpServer.formatAddress(server.address)
return Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) => {
const source = request instanceof Request ? request : new Request(request, init)
const url = new URL(source.url)
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
}),
)
}
function authorization(username: string, password: string) {
@@ -204,22 +200,14 @@ function httpapiInstance<A, E>(
Effect.gen(function* () {
const instance = yield* TestInstance
yield* options.setup?.(instance.directory) ?? Effect.void
return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory })
}),
{ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
)
}
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
it.live(
name,
Effect.gen(function* () {
const standard = yield* scenario("default")
yield* resetState()
const raw = yield* scenario("raw")
expect(raw).toEqual(standard)
}),
)
it.live(name, scenario("raw"))
}
function withProject<A, E, E2 = never>(
@@ -237,7 +225,7 @@ function withProject<A, E, E2 = never>(
config: { formatter: false, lsp: false, ...options.config },
})
yield* options.setup?.(directory) ?? Effect.void
return yield* run({ sdk: client(serverPath, directory), directory })
return yield* run({ sdk: yield* client(serverPath, directory), directory })
})
}
@@ -310,9 +298,9 @@ function seedMessage(directory: string, sessionID: string) {
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
tools: {},
} satisfies MessageV2.User)
} satisfies SessionLegacy.User)
const part = yield* svc.updatePart({
id: PartID.ascending(),
sessionID: id,
@@ -338,7 +326,7 @@ describe("HttpApi SDK", () => {
httpapi(
"uses the generated SDK for global and control routes",
Effect.gen(function* () {
const sdk = client("raw")
const sdk = yield* client("raw")
const health = yield* call(() => sdk.global.health())
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
@@ -380,7 +368,7 @@ describe("HttpApi SDK", () => {
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
Effect.gen(function* () {
const sdk = client(serverPath)
const sdk = yield* client(serverPath)
const health = yield* capture(() => sdk.global.health())
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
@@ -394,9 +382,11 @@ describe("HttpApi SDK", () => {
)
serverPathParity("matches generated SDK global event stream", (serverPath) =>
firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
Effect.map((event) => ({ type: record(record(event).payload).type })),
),
Effect.gen(function* () {
const sdk = yield* client(serverPath)
const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
return { type: record(record(event).payload).type }
}),
)
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
@@ -441,12 +431,13 @@ describe("HttpApi SDK", () => {
withStandardProject(serverPath, ({ directory }) =>
Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const fetch = yield* serverFetch(serverPath)
const thrown = yield* captureThrown(() =>
validateSession({
url: "http://localhost",
directory,
sessionID,
fetch: serverFetch(serverPath),
fetch,
}),
)
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
@@ -460,21 +451,18 @@ describe("HttpApi SDK", () => {
{ serverPath: "raw", setup: writeStandardFiles },
({ directory }) =>
Effect.gen(function* () {
const missing = yield* capture(() =>
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
)
const bad = yield* capture(() =>
client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "wrong") },
}).file.read({ path: "hello.txt" }),
)
const good = yield* capture(() =>
client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "secret") },
}).file.read({ path: "hello.txt" }),
)
const missingSdk = yield* client("raw", directory, { password: "secret" })
const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
const badSdk = yield* client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "wrong") },
})
const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
const goodSdk = yield* client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "secret") },
})
const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
return {
statuses: statuses({ missing, bad, good }),
@@ -640,7 +628,7 @@ describe("HttpApi SDK", () => {
),
)
// Regression: SyncEvent must publish on the same ProjectBus the /event handler
// Regression: EventV2 must publish on the same ProjectBus the /event handler
// subscribes to, AND the /event stream must forward handler ALS/context into the
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
@@ -1,27 +1,31 @@
import { afterEach, describe, expect } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Cause, Effect, Exit, Layer } from "effect"
import { Cause, Config, Effect, Exit, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http"
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors"
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 "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { Database } from "@opencode-ai/core/database/database"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
@@ -45,11 +49,28 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe(
Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
),
)
const it = testEffect(Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
function app() {
return Server.Default().app
}
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{
disableListenLog: true,
disableLogger: true,
},
)
const httpApiLayer = servedRoutes.pipe(
Layer.provide(layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
const it = testEffect(
Layer.mergeAll(
instanceStoreLayer,
Project.defaultLayer,
Session.defaultLayer,
workspaceLayer,
Database.defaultLayer,
httpApiLayer,
),
)
function pathFor(path: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
@@ -67,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const part = yield* svc.updatePart({
@@ -109,7 +130,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
)
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
Effect.sync(() => {
Effect.gen(function* () {
const message = new SessionMessage.Assistant({
id: SessionMessage.ID.create(),
type: "assistant",
@@ -122,90 +143,93 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
time: { created: DateTime.makeUnsafe(time) },
content: [],
})
Database.use((db) =>
db
.insert(SessionMessageTable)
.values([
{
id: message.id,
session_id: sessionID,
type: message.type,
time_created: time,
data: {
time: { created: time },
agent: message.agent,
model: message.model,
content: message.content,
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run(),
)
const { db } = yield* Database.Service
yield* db
.insert(SessionMessageTable)
.values([
{
id: message.id,
session_id: sessionID,
type: message.type,
time_created: time,
data: {
time: { created: time },
agent: message.agent,
model: message.model,
content: message.content,
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run()
.pipe(Effect.orDie)
})
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
Effect.sync(() =>
Database.use((db) =>
db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.create(),
session_id: sessionID,
type: "assistant",
time_created: time,
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.create(),
session_id: sessionID,
type: "assistant",
time_created: time,
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run()
.pipe(Effect.orDie)
})
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) =>
db
.update(SessionTable)
.set({
summary_additions: 1,
summary_deletions: 0,
summary_files: 1,
summary_diffs: [{ additions: 1, deletions: 0 }],
})
.where(eq(SessionTable.id, sessionID))
.run(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({
summary_additions: 1,
summary_deletions: 0,
summary_files: 1,
summary_diffs: [{ additions: 1, deletions: 0 }],
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
})
const getWorkspaceID = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
})
const clearSessionPath = (sessionID: SessionIDType) =>
Effect.sync(() =>
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run()),
)
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
})
function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
const url = new URL(path, "http://localhost")
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
HttpClientRequest.setUrl(url.pathname),
HttpClient.execute,
)
}
function json<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
return (await response.json()) as T
})
function json<T>(response: HttpClientResponse.HttpClientResponse) {
if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text))))
return response.json.pipe(Effect.map((value) => value as T))
}
function responseJson(response: Response) {
return Effect.promise(() => response.json())
function responseJson(response: HttpClientResponse.HttpClientResponse) {
return response.json
}
function requestJson<T>(path: string, init?: RequestInit) {
@@ -338,8 +362,8 @@ describe("session HttpApi", () => {
const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
headers,
})
const messagePage = yield* json<MessageV2.WithParts[]>(messages)
const nextCursor = messages.headers.get("x-next-cursor")
const messagePage = yield* json<SessionLegacy.WithParts[]>(messages)
const nextCursor = messages.headers["x-next-cursor"]
expect(nextCursor).toBeTruthy()
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
@@ -355,7 +379,7 @@ describe("session HttpApi", () => {
).toBe(400)
expect(
yield* requestJson<MessageV2.WithParts>(
yield* requestJson<SessionLegacy.WithParts>(
pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
{ headers },
),
@@ -788,9 +812,9 @@ describe("session HttpApi", () => {
const response = yield* request(route, { headers })
expect(response.headers.get("x-next-cursor")).toBeTruthy()
expect(response.headers.get("link")).toContain("limit=1")
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
expect(response.headers["x-next-cursor"]).toBeTruthy()
expect(response.headers["link"]).toContain("limit=1")
expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
@@ -805,7 +829,7 @@ describe("session HttpApi", () => {
const first = yield* createTextMessage(session.id, "first")
const second = yield* createTextMessage(session.id, "second")
const updated = yield* requestJson<MessageV2.Part>(
const updated = yield* requestJson<SessionLegacy.Part>(
pathFor(SessionPaths.updatePart, {
sessionID: session.id,
messageID: first.info.id,
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
import { Context, Effect } from "effect"
import { Context, Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Session } from "@/session/session"
@@ -9,16 +8,13 @@ import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Session.defaultLayer)
function app() {
return Server.Default().app
}
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
afterEach(async () => {
mock.restore()
@@ -38,23 +34,17 @@ describe("sync HttpApi", () => {
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = yield* Session.use.create({ title: "sync" })
const started = yield* Effect.promise(() =>
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
)
const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
expect(started.status).toBe(200)
expect(yield* Effect.promise(() => started.json())).toBe(true)
expect(yield* started.json).toBe(true)
const history = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.history, {
method: "POST",
headers,
body: JSON.stringify({}),
}),
),
)
const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(history.status).toBe(200)
const rows = (yield* Effect.promise(() => history.json())) as Array<{
const rows = (yield* history.json) as Array<{
id: string
aggregate_id: string
seq: number
@@ -63,28 +53,24 @@ describe("sync HttpApi", () => {
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const replayed = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.replay, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.directory,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}),
}),
),
)
const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.directory,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}),
})
expect(replayed.status).toBe(200)
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
expect(yield* replayed.json).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
}),
@@ -123,15 +109,11 @@ describe("sync HttpApi", () => {
]
for (const item of cases) {
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
}),
),
)
const response = yield* requestInDirectory(item.path, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(response.status).toBe(400)
}
}),
@@ -16,10 +16,11 @@ import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Database } from "@opencode-ai/core/database/database"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
@@ -30,7 +31,6 @@ import {
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { Database } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
import { tmpdirScoped } from "../fixture/fixture"
@@ -54,6 +54,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Database.defaultLayer,
Project.defaultLayer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
@@ -165,10 +166,11 @@ const insertRemoteWorkspaceWithoutSync = (input: {
type: string
url: string
}) =>
Effect.sync(() => {
const id = WorkspaceID.ascending()
Effect.gen(function* () {
const id = WorkspaceV2.ID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
const { db } = yield* Database.Service
yield* db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run().pipe(Effect.orDie)
return id
})
@@ -327,9 +329,9 @@ describe("HttpApi workspace routing middleware", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceID = WorkspaceID.ascending()
const workspaceID = WorkspaceV2.ID.ascending()
const type = "remote-http-fence-target"
const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record<string, number> } | undefined>(undefined)
const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record<string, number> } | undefined>(undefined)
const remoteUrl = yield* startRemoteWorkspaceHttpServer(() =>
HttpServerResponse.json(
@@ -438,7 +440,7 @@ describe("HttpApi workspace routing middleware", () => {
it.live("returns a missing workspace response for unknown workspace ids", () =>
Effect.gen(function* () {
const workspaceID = WorkspaceID.ascending("wrk_missing")
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* serveProbe
@@ -1,16 +1,16 @@
import { afterEach, describe, expect, mock } from "bun:test"
import { NodeServices } from "@effect/platform-node"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect, Layer } from "effect"
import { Effect, Layer, Stream } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Session } from "@/session/session"
import { Database } from "@opencode-ai/core/database/database"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
@@ -19,8 +19,8 @@ import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspaceRef } from "../../src/effect/instance-ref"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
@@ -29,14 +29,29 @@ const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
const it = testEffect(
Layer.mergeAll(
Project.defaultLayer,
Session.defaultLayer,
workspaceLayer,
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
Database.defaultLayer,
httpApiLayer,
),
)
function request(path: string, directory: string, init: RequestInit = {}) {
return Effect.promise(() => {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
})
return requestInDirectory(path, directory, init)
}
function requestDefault(path: string, directory: string, init: RequestInit = {}) {
return requestInDirectory(path, directory, init)
}
function requestServer(path: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
}
function localAdapter(directory: string): WorkspaceAdapter {
@@ -180,17 +195,17 @@ describe("workspace HttpApi", () => {
])
expect(adapters.status).toBe(200)
expect(yield* Effect.promise(() => adapters.json())).toContainEqual({
expect(yield* adapters.json).toContainEqual({
type: "worktree",
name: "Worktree",
description: "Create a git worktree",
})
expect(workspaces.status).toBe(200)
expect(yield* Effect.promise(() => workspaces.json())).toEqual([])
expect(yield* workspaces.json).toEqual([])
expect(status.status).toBe(200)
expect(yield* Effect.promise(() => status.json())).toEqual([])
expect(yield* status.json).toEqual([])
}),
)
@@ -207,7 +222,7 @@ describe("workspace HttpApi", () => {
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const workspace = (yield* created.json) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
@@ -220,11 +235,11 @@ describe("workspace HttpApi", () => {
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id })
expect(yield* removed.json).toMatchObject({ id: workspace.id })
const listed = yield* request(WorkspacePaths.list, dir)
expect(listed.status).toBe(200)
expect(yield* Effect.promise(() => listed.json())).toEqual([])
expect(yield* listed.json).toEqual([])
}),
)
@@ -240,7 +255,7 @@ describe("workspace HttpApi", () => {
expect(response.status).toBe(204)
const listed = yield* request(WorkspacePaths.list, dir)
expect(yield* Effect.promise(() => listed.json())).toMatchObject([
expect(yield* listed.json).toMatchObject([
{
type,
name: "listed-test",
@@ -256,7 +271,7 @@ describe("workspace HttpApi", () => {
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
const workspaceID = WorkspaceID.ascending("wrk_missing_warp")
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
const response = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
@@ -265,7 +280,7 @@ describe("workspace HttpApi", () => {
})
expect(response.status).toBe(404)
expect(yield* Effect.promise(() => response.json())).toEqual({
expect(yield* response.json).toEqual({
name: "NotFoundError",
data: { message: `Workspace not found: ${workspaceID}` },
})
@@ -286,7 +301,7 @@ describe("workspace HttpApi", () => {
})
expect(created.status).toBe(200)
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
expect((yield* created.json) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
})
@@ -298,7 +313,7 @@ describe("workspace HttpApi", () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const created = yield* request(WorkspacePaths.list, dir, {
const created = yield* requestServer(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
@@ -323,7 +338,7 @@ describe("workspace HttpApi", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const workspace = (yield* created.json) as Workspace.Info
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
@@ -331,7 +346,7 @@ describe("workspace HttpApi", () => {
const response = yield* request(url.toString(), dir)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir })
expect(yield* response.json).toMatchObject({ directory: workspaceDir })
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}),
)
@@ -374,19 +389,19 @@ describe("workspace HttpApi", () => {
"x-target-auth": "secret",
}),
)
const created = yield* request(WorkspacePaths.list, dir, {
const created = yield* requestDefault(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const workspace = (yield* created.json) as Workspace.Info
const url = new URL("http://localhost/config")
url.searchParams.set("workspace", workspace.id)
url.searchParams.set("keep", "yes")
try {
const response = yield* request(url.toString(), dir, {
const response = yield* requestDefault(url.toString(), dir, {
method: "PATCH",
headers: {
"accept-encoding": "br",
@@ -396,10 +411,10 @@ describe("workspace HttpApi", () => {
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = yield* Effect.promise(() => response.text())
const responseBody = yield* response.text
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers.get("content-length")).toBeNull()
expect(response.headers.get("x-remote")).toBe("yes")
expect(response.headers["content-length"]).toBeUndefined()
expect(response.headers["x-remote"]).toBe("yes")
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
expect(forwarded).toEqual([
@@ -420,16 +435,13 @@ describe("workspace HttpApi", () => {
eventURL.searchParams.set("workspace", workspace.id)
const eventResponse = yield* request(eventURL.toString(), dir)
expect(eventResponse.status).toBe(200)
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
if (!eventResponse.body) throw new Error("missing proxied event response body")
const eventReader = eventResponse.body.getReader()
const event = yield* Effect.promise(() => eventReader.read())
yield* Effect.promise(() => eventReader.cancel())
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
expect(new TextDecoder().decode(event)).toContain("server.connected")
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
@@ -453,24 +465,29 @@ describe("workspace HttpApi", () => {
"remote-session-target",
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
)
const created = yield* request(WorkspacePaths.list, dir, {
const created = yield* requestDefault(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.use
.create()
.pipe(Effect.provideService(WorkspaceRef, workspace.id), provideInstance(dir))
const workspace = (yield* created.json) as Workspace.Info
const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
const session = (yield* sessionResponse.json) as Session.Info
const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(warped.status).toBe(204)
try {
const response = yield* request(`http://localhost/session/${session.id}/message`, dir, {
const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
})
const responseBody = yield* Effect.promise(() => response.text())
const responseBody = yield* response.text
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
@@ -491,7 +508,7 @@ describe("workspace HttpApi", () => {
])
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
@@ -6,20 +6,21 @@
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID } from "../../src/session/schema"
import * as Database from "@/storage/db"
import { PartTable } from "@/session/session.sql"
import { Database } from "@opencode-ai/core/database/database"
import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Session.defaultLayer)
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
function seedNegativeTokenSession() {
return Effect.gen(function* () {
@@ -30,7 +31,7 @@ function seedNegativeTokenSession() {
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
@@ -46,20 +47,20 @@ function seedNegativeTokenSession() {
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
@@ -73,7 +74,7 @@ describe("messages endpoint tolerates legacy negative token counts", () => {
const test = yield* TestInstance
const sessionID = yield* seedNegativeTokenSession()
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const res = yield* requestInDirectory(url, test.directory)
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
{ git: true, config: { formatter: false, lsp: false } },
@@ -1,17 +1,18 @@
import { afterEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import path from "path"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
@@ -23,18 +24,16 @@ afterEach(async () => {
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore))
const it = testEffect(
Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer),
)
function request(directory: string, url: string, init: RequestInit = {}) {
return Effect.promise(() => {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Promise.resolve(Server.Default().app.request(url, { ...init, headers }))
})
return requestInDirectory(url, directory, init)
}
function json<T>(response: Response) {
return Effect.promise(() => response.json() as Promise<T>)
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
function collectGlobalEvents() {
@@ -1,14 +1,14 @@
import { afterEach, describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Server } from "../../src/server/server"
import { Effect, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const it = testEffect(SessionNs.defaultLayer)
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
afterEach(async () => {
mock.restore()
@@ -21,73 +21,52 @@ describe("session action routes", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const app = Server.Default().app
const headers = { "Content-Type": "application/json", "x-opencode-directory": test.directory }
const headers = { "Content-Type": "application/json" }
const created = yield* Effect.promise(() =>
Promise.resolve(
app.request("/session", {
method: "POST",
headers,
body: JSON.stringify({
title: "meta-session",
metadata: { source: "sdk", trace: { id: "abc" } },
}),
}),
),
)
const created = yield* requestInDirectory("/session", test.directory, {
method: "POST",
headers,
body: JSON.stringify({
title: "meta-session",
metadata: { source: "sdk", trace: { id: "abc" } },
}),
})
expect(created.status).toBe(200)
const session = (yield* Effect.promise(() => created.json())) as SessionNs.Info
const session = (yield* created.json) as SessionNs.Info
expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } })
const updated = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
}),
),
)
const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
})
expect(updated.status).toBe(200)
const next = (yield* Effect.promise(() => updated.json())) as SessionNs.Info
const next = (yield* updated.json) as SessionNs.Info
expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] })
const fetched = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }),
),
)
const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory)
expect(fetched.status).toBe(200)
expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata)
expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata)
const forked = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}/fork`, {
method: "POST",
headers,
body: JSON.stringify({}),
}),
),
)
const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(forked.status).toBe(200)
const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info
const fork = (yield* forked.json) as SessionNs.Info
expect(fork.metadata).toEqual(next.metadata)
const reset = yield* Effect.promise(() =>
Promise.resolve(
app.request(`/session/${session.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: {} }),
}),
),
)
const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: {} }),
})
expect(reset.status).toBe(200)
expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({})
expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({})
yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore))
yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))
@@ -104,17 +83,10 @@ describe("session action routes", () => {
SessionNs.use.remove(created.id).pipe(Effect.ignore),
)
const res = yield* Effect.promise(() =>
Promise.resolve(
Server.Default().app.request(`/session/${session.id}/abort`, {
method: "POST",
headers: { "x-opencode-directory": test.directory },
}),
),
)
const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" })
expect(res.status).toBe(200)
expect(yield* Effect.promise(() => res.json())).toBe(true)
expect(yield* res.json).toBe(true)
}),
{ git: true },
)
@@ -11,7 +11,6 @@
*/
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Server } from "@/server/server"
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { Storage } from "@/storage/storage"
@@ -19,10 +18,11 @@ import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import * as Log from "@opencode-ai/core/util/log"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer))
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer))
afterEach(async () => {
await disposeAllInstances()
@@ -51,16 +51,13 @@ describe("session diff with missing patch (#26574)", () => {
storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
)
const response = yield* Effect.promise(() =>
Promise.resolve(
Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), {
headers: { "x-opencode-directory": test.directory },
}),
),
const response = yield* requestInDirectory(
pathFor(SessionPaths.diff, { sessionID: session.id }),
test.directory,
)
expect(response.status).toBe(200)
const body = (yield* Effect.promise(() => response.json())) as Array<{
const body = (yield* response.json) as Array<{
file: string
patch?: string
additions: number
@@ -1,28 +1,33 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { mkdir } from "fs/promises"
import path from "path"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { testEffect } from "../lib/effect"
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "@/sync"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
void Log.init({ print: false })
const it = testEffect(
SessionNs.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Storage.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
Layer.mergeAll(
Database.defaultLayer,
SessionNs.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
),
),
)
@@ -148,16 +153,9 @@ describe("session.list", () => {
provideInstance(path.join(test.directory, "packages", "app")),
)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(),
),
)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(),
),
)
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run().pipe(Effect.orDie)
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run().pipe(Effect.orDie)
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
@@ -1,21 +1,24 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Server } from "../../src/server/server"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const it = testEffect(SessionNs.defaultLayer)
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
const model = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test"),
providerID: ProviderV2.ID.make("test"),
modelID: ProviderV2.ModelID.make("test"),
}
afterEach(async () => {
@@ -62,25 +65,25 @@ const fill = Effect.fn("SessionMessagesTest.fill")(function* (
agent: "test",
model,
tools: {},
} satisfies MessageV2.User)
} satisfies SessionLegacy.User)
yield* session.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
type: "text",
text: `m${i}`,
} satisfies MessageV2.TextPart)
} satisfies SessionLegacy.TextPart)
return id
}),
)
})
function request(path: string) {
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path)))
return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory)))
}
function json<T>(response: Response) {
return Effect.promise(() => response.json() as Promise<T>)
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((body) => body as T))
}
describe("session messages endpoint", () => {
@@ -93,15 +96,15 @@ describe("session messages endpoint", () => {
const a = yield* request(`/session/${session.id}/message?limit=2`)
expect(a.status).toBe(200)
const aBody = yield* json<MessageV2.WithParts[]>(a)
const aBody = yield* json<SessionLegacy.WithParts[]>(a)
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
const cursor = a.headers.get("x-next-cursor")
const cursor = a.headers["x-next-cursor"]
expect(cursor).toBeTruthy()
expect(a.headers.get("link")).toContain('rel="next"')
expect(a.headers["link"]).toContain('rel="next"')
const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
expect(b.status).toBe(200)
const bBody = yield* json<MessageV2.WithParts[]>(b)
const bBody = yield* json<SessionLegacy.WithParts[]>(b)
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
}),
),
@@ -117,7 +120,7 @@ describe("session messages endpoint", () => {
const res = yield* request(`/session/${session.id}/message`)
expect(res.status).toBe(200)
const body = yield* json<MessageV2.WithParts[]>(res)
const body = yield* json<SessionLegacy.WithParts[]>(res)
expect(body.map((item) => item.info.id)).toEqual(ids)
}),
),
@@ -149,7 +152,7 @@ describe("session messages endpoint", () => {
const res = yield* request(`/session/${session.id}/message?limit=510`)
expect(res.status).toBe(200)
const body = yield* json<MessageV2.WithParts[]>(res)
const body = yield* json<SessionLegacy.WithParts[]>(res)
expect(body).toHaveLength(510)
}),
),
@@ -1,14 +1,14 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
void Log.init({ print: false })
const it = testEffect(Session.defaultLayer)
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
describe("tui.selectSession endpoint", () => {
it.instance(
@@ -18,22 +18,14 @@ describe("tui.selectSession endpoint", () => {
const tmp = yield* TestInstance
const session = yield* Session.use.create({})
const app = Server.Default().app
const response = yield* Effect.promise(() =>
Promise.resolve(
app.request("/tui/select-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": tmp.directory,
},
body: JSON.stringify({ sessionID: session.id }),
}),
),
)
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(response.status).toBe(200)
const body = yield* Effect.promise(() => response.json())
const body = yield* response.json
expect(body).toBe(true)
}),
{ git: true },
@@ -46,19 +38,11 @@ describe("tui.selectSession endpoint", () => {
const tmp = yield* TestInstance
const nonExistentSessionID = "ses_nonexistent123"
const app = Server.Default().app
const response = yield* Effect.promise(() =>
Promise.resolve(
app.request("/tui/select-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": tmp.directory,
},
body: JSON.stringify({ sessionID: nonExistentSessionID }),
}),
),
)
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: nonExistentSessionID }),
})
expect(response.status).toBe(404)
}),
@@ -72,19 +56,11 @@ describe("tui.selectSession endpoint", () => {
const tmp = yield* TestInstance
const invalidSessionID = "invalid_session_id"
const app = Server.Default().app
const response = yield* Effect.promise(() =>
Promise.resolve(
app.request("/tui/select-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-opencode-directory": tmp.directory,
},
body: JSON.stringify({ sessionID: invalidSessionID }),
}),
),
)
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: invalidSessionID }),
})
expect(response.status).toBe(400)
}),
@@ -1,10 +1,9 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Worktree } from "@/worktree"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { resetDatabase } from "../fixture/db"
@@ -30,19 +29,16 @@ const stateLayer = Layer.effectDiscard(
const it = testEffect(stateLayer)
const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
type TestServer = ReturnType<typeof Server.Default>["app"]
type CreatedWorktree = { directory: string }
type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
function serverScoped() {
return Effect.acquireRelease(
Effect.sync(() => HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true })),
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
)
return Effect.sync(() => Server.Default().app)
}
function request(server: TestServer, input: string, init?: RequestInit) {
return Effect.promise(() => server.handler(new Request(new URL(input, "http://localhost"), init), HttpApiApp.context))
return Effect.promise(() => Promise.resolve(server.request(input, init)))
}
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {