resolve merge conflicts

This commit is contained in:
Imanol Maiztegui
2026-05-15 13:13:17 +02:00
276 changed files with 22732 additions and 15265 deletions
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Option, Redacted } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "../../src/server/auth"
const original = {
KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD,
KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME,
}
afterEach(() => {
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
})
describe("ServerAuth", () => {
test("does not emit auth headers without a password", () => {
Flag.KILO_SERVER_PASSWORD = undefined
Flag.KILO_SERVER_USERNAME = "alice"
expect(ServerAuth.header()).toBeUndefined()
expect(ServerAuth.headers()).toBeUndefined()
})
test("defaults to the opencode username", () => {
Flag.KILO_SERVER_PASSWORD = "secret"
Flag.KILO_SERVER_USERNAME = undefined
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
})
})
test("uses the configured username", () => {
Flag.KILO_SERVER_PASSWORD = "secret"
Flag.KILO_SERVER_USERNAME = "alice"
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
})
})
test("prefers explicit credentials", () => {
Flag.KILO_SERVER_PASSWORD = "secret"
Flag.KILO_SERVER_USERNAME = "alice"
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
})
})
test("validates decoded credentials against effect config", () => {
const config = { password: Option.some("secret"), username: "alice" }
expect(ServerAuth.required(config)).toBe(true)
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
})
})
@@ -0,0 +1,34 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Cause, Effect } from "effect"
export function waitGlobalBusEvent(input: {
timeout?: number
message?: string
predicate: (event: GlobalEvent) => boolean
}) {
return Effect.callback<GlobalEvent, unknown>((resume) => {
const cleanup = () => GlobalBus.off("event", handler)
const handler = (event: GlobalEvent) => {
try {
if (!input.predicate(event)) return
cleanup()
resume(Effect.succeed(event))
} catch (error) {
cleanup()
resume(Effect.fail(error))
}
}
GlobalBus.on("event", handler)
return Effect.sync(cleanup)
}).pipe(
Effect.timeout(input.timeout ?? 10_000),
Effect.mapError((error) =>
Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error,
),
)
}
export const waitGlobalBusEventPromise = (input: Parameters<typeof waitGlobalBusEvent>[0]) =>
Effect.runPromise(waitGlobalBusEvent(input))
@@ -4,6 +4,7 @@ import { Effect } from "effect"
import path from "path"
import z from "zod"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Project } from "@/project/project"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
@@ -45,11 +46,11 @@ describe("session.listGlobal", () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({
const firstSession = await WithInstance.provide({
directory: first.path,
fn: async () => svc.create({ title: "first-session" }),
})
const secondSession = await Instance.provide({
const secondSession = await WithInstance.provide({
directory: second.path,
fn: async () => svc.create({ title: "second-session" }),
})
@@ -75,12 +76,12 @@ describe("session.listGlobal", () => {
test("excludes archived sessions by default", async () => {
await using tmp = await tmpdir({ git: true })
const archived = await Instance.provide({
const archived = await WithInstance.provide({
directory: tmp.path,
fn: async () => svc.create({ title: "archived-session" }),
})
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }),
})
@@ -99,12 +100,12 @@ describe("session.listGlobal", () => {
test("supports cursor pagination", async () => {
await using tmp = await tmpdir({ git: true })
const first = await Instance.provide({
const first = await WithInstance.provide({
directory: tmp.path,
fn: async () => svc.create({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
const second = await WithInstance.provide({
directory: tmp.path,
fn: async () => svc.create({ title: "page-two" }),
})
@@ -2,12 +2,9 @@ import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import {
Authorization,
ServerAuthConfig,
authorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
import { ServerAuth } from "../../src/server/auth"
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
const Api = HttpApi.make("test-authorization").add(
@@ -16,27 +13,34 @@ const Api = HttpApi.make("test-authorization").add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
HttpApiEndpoint.get("missing", "/missing", {
success: Schema.String,
error: HttpApiError.NotFound,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
handlers
.handle("probe", () => Effect.succeed("ok"))
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
const basic = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
@@ -93,6 +97,35 @@ describe("HttpApi authorization middleware", () => {
}),
)
itSecret.live("prefers auth token query credentials over basic auth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get(
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
expect(response.status).toBe(200)
}),
)
itSecret.live("preserves handler errors when basic auth succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get("/missing").pipe(
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
HttpClient.execute,
)
expect(response.status).toBe(404)
}),
)
itSecret.live("preserves handler errors when auth token query succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(404)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
@@ -228,15 +228,22 @@ describe("HttpApi server", () => {
// and is not enabled in any production client (VS Code extension, JetBrains, TUI, desktop all use Hono).
// Follow-up: migrate Kilo overlay routes onto the Effect HttpApi bridge.
test.skip("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapi())
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
"GET /api/session",
"GET /api/session/{sessionID}/context",
"GET /api/session/{sessionID}/message",
"POST /api/session/{sessionID}/compact",
"POST /api/session/{sessionID}/prompt",
"POST /api/session/{sessionID}/wait",
])
})
test.skip("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapi())
const hono = openApiParameters(await Server.openapiHono())
const effect = openApiParameters(effectOpenApi())
expect(
@@ -247,7 +254,7 @@ describe("HttpApi server", () => {
})
test.skip("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapi())
const hono = openApiRequestBodies(await Server.openapiHono())
const effect = openApiRequestBodies(effectOpenApi())
expect(
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
await waitGlobalBusEventPromise({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
@@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) {
return new TextDecoder().decode(result.value)
}
async function readFirstEvent(response: Response) {
return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as {
id?: string
type: string
properties: Record<string, unknown>
}
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
@@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => {
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(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
})
test("matches legacy first event frame", async () => {
@@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => {
const legacy = await app(false).request(EventPaths.event, { headers })
const effect = await app(true).request(EventPaths.event, { headers })
expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
const legacyEvent = await readFirstEvent(legacy)
const effectEvent = await readFirstEvent(effect)
expect(effectEvent.type).toBe(legacyEvent.type)
expect(effectEvent.properties).toEqual(legacyEvent.properties)
})
})
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session"
@@ -11,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
@@ -31,20 +32,9 @@ function createSession(input?: Session.CreateInput) {
}
async function waitReady(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
await waitGlobalBusEventPromise({
message: "timed out waiting for worktree.ready",
predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory,
})
}
@@ -140,12 +130,12 @@ describe("experimental HttpApi", () => {
test("serves global session list through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const first = await Instance.provide({
const first = await WithInstance.provide({
directory: tmp.path,
fn: async () => createSession({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
const second = await WithInstance.provide({
directory: tmp.path,
fn: async () => createSession({ title: "page-two" }),
})
@@ -1,6 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -11,15 +10,15 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { waitGlobalBusEvent } from "./global-bus"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
@@ -42,8 +41,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
InstanceLayer.layer,
Project.defaultLayer,
Workspace.defaultLayer,
),
@@ -97,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build,
)
const waitDisposedEvent = Effect.promise(
() =>
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const waitDisposedEvent = waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed",
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
const serveDisposeProbe = () =>
HttpRouter.serve(
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
await waitGlobalBusEventPromise({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
@@ -120,13 +108,9 @@ describe("instance HttpApi", () => {
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
const disposed = waitGlobalBusEventPromise({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed",
})
const response = await app().request(InstancePaths.dispose, {
@@ -136,6 +120,6 @@ describe("instance HttpApi", () => {
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
expect((await disposed).directory).toBe(tmp.path)
})
})
@@ -0,0 +1,319 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = {
KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI,
KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD,
KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME,
envPassword: process.env.KILO_SERVER_PASSWORD,
envUsername: process.env.KILO_SERVER_USERNAME,
}
const auth = { username: "opencode", password: "listen-secret" }
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
if (original.envPassword === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = original.envPassword
if (original.envUsername === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = original.envUsername
await disposeAllInstances()
await resetDatabase()
})
async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
Flag.KILO_SERVER_PASSWORD = auth.password
Flag.KILO_SERVER_USERNAME = auth.username
process.env.KILO_SERVER_PASSWORD = auth.password
process.env.KILO_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
Flag.KILO_SERVER_PASSWORD = undefined
Flag.KILO_SERVER_USERNAME = auth.username
delete process.env.KILO_SERVER_PASSWORD
process.env.KILO_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
function authorization() {
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
url.protocol = "ws:"
url.searchParams.set("directory", dir)
url.searchParams.set("cursor", "-1")
if (ticket) url.searchParams.set("ticket", ticket)
return url
}
async function requestTicket(
listener: Awaited<ReturnType<typeof startListener>>,
id: string,
dir: string,
options?: { ticketHeader?: boolean; origin?: string },
) {
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-kilo-directory": dir,
...(options?.ticketHeader === false ? {} : { "x-kilo-ticket": "1" }),
...(options?.origin ? { origin: options.origin } : {}),
},
})
return response
}
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
const response = await requestTicket(listener, id, dir)
expect(response.status).toBe(200)
return (await response.json()) as { ticket: string; expires_in: number }
}
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
const response = await fetch(new URL(PtyPaths.create, listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-kilo-directory": dir,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
})
expect(response.status).toBe(200)
return (await response.json()) as { id: string }
}
async function openSocket(url: URL) {
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
}),
5_000,
"timed out waiting for websocket open",
)
return ws
}
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
const ws = new Ctor(url, init)
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener(
"open",
() => {
ws.close(1000)
reject(new Error("websocket opened"))
},
{ once: true },
)
ws.addEventListener("error", () => resolve(), { once: true })
ws.addEventListener("close", () => resolve(), { once: true })
}),
5_000,
"timed out waiting for websocket rejection",
)
}
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
return withTimeout(listener.stop(true), 10_000, label)
}
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
const decoder = new TextDecoder()
let onMessage: ((event: MessageEvent) => void) | undefined
return withTimeout(
new Promise<string>((resolve) => {
onMessage = (event: MessageEvent) => {
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
if (!predicate(message)) return
resolve(message)
}
ws.addEventListener("message", onMessage)
}),
5_000,
"timed out waiting for websocket message",
).finally(() => {
if (onMessage) ws.removeEventListener("message", onMessage)
})
}
describe("HttpApi Server.listen", () => {
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
headers: { authorization: authorization(), "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
}),
]),
)
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
expect(ticket.expires_in).toBeGreaterThan(0)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
ws.send("ping-listen\n")
expect(await message).toContain("ping-listen")
await stop(listener, "timed out waiting for listener.stop(true)")
stopped = true
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
expect(ws.readyState).toBe(WebSocket.CLOSED)
const restarted = await startListener()
try {
const nextInfo = await createCat(restarted, tmp.path)
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
nextWs.send("ping-restarted\n")
expect(await nextMessage).toContain("ping-restarted")
nextWs.close(1000)
} finally {
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
}
} finally {
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
}
})
testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener("hono")
try {
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
ws.send("ping-hono-ticket\n")
expect(await message).toContain("ping-hono-ticket")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
}
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
const reusable = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
ws.close(1000)
const other = await createCat(listener, tmp.path)
const scoped = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
headers: { origin: "https://evil.example" },
})
} finally {
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
}
})
// Regression for #25698 (Ope): the app's SDK call to
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
// the server resolved the PTY in its own cwd context — where the project
// PTY isn't registered — and returned 404. The fix is to always pass
// `directory` from the app side; this test locks in two contracts:
// 1. Mint without directory cannot find a PTY registered in another dir.
// 2. Mint with the project directory succeeds; the resulting ticket
// consumes cleanly when the WS upgrade carries the same directory.
testPty("PTY connect token requires matching directory across mint and connect", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
// Mint without directory — server uses its own cwd, can't find the PTY.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-kilo-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
// Mint with the project directory — succeeds, ticket binds to that scope.
const scoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-kilo-ticket": "1" },
},
)
expect(scoped.status).toBe(200)
const mint = (await scoped.json()) as { ticket: string }
// Same directory on the WS upgrade → consume succeeds.
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
}
})
for (const backend of ["effect-httpapi", "hono"] as const) {
testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener(backend)
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`))
ws.send(`ping-no-auth-${backend}\n`)
expect(await message).toContain(`ping-no-auth-${backend}`)
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
}
})
@@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect) => effect,
authToken: (effect) => effect,
}),
Authorization.of((effect) => effect),
)
const passthroughInstanceContext = Layer.succeed(
@@ -5,7 +5,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -59,7 +60,7 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
)
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { MessageID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
function app(experimental: boolean) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
function createSessionWithMessages(directory: string, count: number) {
return WithInstance.provide({
directory,
fn: async () => {
const session = await runSession(Session.Service.use((svc) => svc.create({})))
for (let i = 0; i < count; i++) {
await runSession(
Effect.gen(function* () {
const svc = yield* Session.Service
yield* svc.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: session.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
}),
)
}
return session.id
},
})
}
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 1: Link header should reflect the request's actual Host header,
// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")`
// which embeds localhost because request.url is path-only. Fix: use
// `HttpServerRequest.toURL(request)` which honors the Host header.
// ──────────────────────────────────────────────────────────────────────────────
describe("Link header host", () => {
test("HttpApi pagination Link header echoes request host", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sessionID = await createSessionWithMessages(tmp.path, 3)
const response = await app(true).request(`/session/${sessionID}/message?limit=2`, {
headers: {
host: "opencode.test:4096",
"x-kilo-directory": tmp.path,
},
})
expect(response.status).toBe(200)
const link = response.headers.get("link")
expect(link).not.toBeNull()
// Link should contain the request's Host, not "localhost".
expect(link).toContain("opencode.test")
expect(link).not.toContain("localhost")
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500.
// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a
// `NotFoundError` from the service surfaces as a defect → 500. Hono's
// equivalent maps to 404 via `errors.notFound`.
//
// Affected endpoints (handlers without mapNotFound): todo, diff, summarize,
// fork, abort, init, deleteMessage, command, shell, revert, unrevert.
//
// FIXME: unskip when mapNotFound coverage is added (next PR).
// ──────────────────────────────────────────────────────────────────────────────
describe("404 mapping for missing session", () => {
test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist/todo", {
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(404)
})
})
// ──────────────────────────────────────────────────────────────────────────────
// Reproducer 3: 404 response body shape should match Hono's NamedError
// envelope `{ name, data: { message } }`. HttpApi returns the typed-error
// shape `{ _tag }` instead. SDK consumers reading `error.data.message`
// see undefined.
//
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
// ──────────────────────────────────────────────────────────────────────────────
describe("Error JSON shape parity", () => {
test.todo("HttpApi 404 body matches NamedError shape", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app(true).request("/session/ses_does_not_exist", {
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(404)
const body = (await response.json()) as { name?: string; data?: { message?: string } }
expect(body.name).toBe("NotFoundError")
expect(typeof body.data?.message).toBe("string")
})
})
@@ -3,7 +3,8 @@ import { Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { WithInstance } from "../../src/project/with-instance"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -91,7 +92,7 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
yield* writeProviderAuthPlugin(dir)
yield* Effect.addFinalizer(() =>
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
@@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createKiloClient } from "@kilocode/sdk/v2"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -241,7 +242,7 @@ function seedMessage(directory: string, sessionID: string) {
const id = SessionID.make(sessionID)
return call(
async () =>
await Instance.provide({
await WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
@@ -9,6 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -16,7 +17,9 @@ import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "../../src/v2/session-message"
import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
@@ -44,7 +47,7 @@ function pathFor(path: string, params: Record<string, string>) {
function createSession(directory: string, input?: Session.CreateInput) {
return Effect.promise(
async () =>
await Instance.provide({
await WithInstance.provide({
directory,
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
}),
@@ -54,7 +57,7 @@ function createSession(directory: string, input?: Session.CreateInput) {
function createTextMessage(directory: string, sessionID: SessionID, text: string) {
return Effect.promise(
async () =>
await Instance.provide({
await WithInstance.provide({
directory,
fn: () =>
runSession(
@@ -202,6 +205,46 @@ describe("session HttpApi", () => {
{ headers },
),
).toMatchObject({ info: { id: message.info.id } })
yield* Effect.promise(() =>
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const message = new SessionMessage.Assistant({
id: SessionMessage.ID.create(),
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
time: { created: DateTime.makeUnsafe(1) },
content: [],
})
Database.use((db) =>
db
.insert(SessionMessageTable)
.values([
{
id: message.id,
session_id: parent.id,
type: message.type,
time_created: 1,
data: {
time: { created: 1 },
agent: message.agent,
model: message.model,
content: message.content,
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
},
])
.run(),
)
},
}),
)
expect(
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
.items,
).toMatchObject([{ type: "assistant" }])
}),
),
)
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { Session } from "@/session/session"
@@ -38,7 +39,7 @@ describe("sync HttpApi", () => {
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = await Instance.provide({
const session = await WithInstance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
})
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
@@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false })
@@ -23,14 +23,9 @@ function app(experimental = true) {
}
function nextCommandExecute() {
return new Promise<unknown>((resolve) => {
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
if (event.payload.type !== TuiEvent.CommandExecute.type) return
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
return waitGlobalBusEventPromise({
predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type,
}).then((event) => event.payload.properties?.command)
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -51,7 +46,7 @@ afterEach(async () => {
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapi()
const legacy = await Server.openapiHono()
const effect = OpenApi.fromApi(TuiApi)
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
@@ -31,7 +32,7 @@ afterEach(async () => {
describe("session action routes", () => {
test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -40,20 +41,20 @@ describe("session.list", () => {
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const parent = await Instance.provide({
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await Instance.provide({
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "current" }),
})
const sibling = await Instance.provide({
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
@@ -73,20 +74,20 @@ describe("session.list", () => {
await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root" })
const parent = await Instance.provide({
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await Instance.provide({
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "current" }),
})
const sibling = await Instance.provide({
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
@@ -106,22 +107,22 @@ describe("session.list", () => {
await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const parent = await Instance.provide({
const parent = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode"),
fn: async () => svc.create({ title: "parent" }),
})
const current = await Instance.provide({
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src"),
fn: async () => svc.create({ title: "current" }),
})
const deeper = await Instance.provide({
const deeper = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src", "deep"),
fn: async () => svc.create({ title: "deeper" }),
})
const sibling = await Instance.provide({
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "sibling" }),
})
@@ -146,14 +147,14 @@ describe("session.list", () => {
await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true })
await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const current = await Instance.provide({
const current = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "opencode", "src"),
fn: async () => svc.create({ title: "legacy-current" }),
})
const sibling = await Instance.provide({
const sibling = await WithInstance.provide({
directory: path.join(tmp.path, "packages", "app"),
fn: async () => svc.create({ title: "legacy-sibling" }),
})
@@ -175,7 +176,7 @@ describe("session.list", () => {
test("filters root sessions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const root = await svc.create({ title: "root-session" })
@@ -192,7 +193,7 @@ describe("session.list", () => {
test("filters by start time", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "new-session" })
@@ -206,7 +207,7 @@ describe("session.list", () => {
test("filters by search term", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "unique-search-term-abc" })
@@ -223,7 +224,7 @@ describe("session.list", () => {
test("respects limit parameter", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await svc.create({ title: "session-1" })
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
@@ -76,7 +77,7 @@ describe("session messages endpoint", () => {
test("returns cursor headers for older pages", async () => {
await using tmp = await tmpdir({ git: true })
await withoutWatcher(() =>
Instance.provide({
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -105,7 +106,7 @@ describe("session messages endpoint", () => {
test("keeps full-history responses when limit is omitted", async () => {
await using tmp = await tmpdir({ git: true })
await withoutWatcher(() =>
Instance.provide({
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -126,7 +127,7 @@ describe("session messages endpoint", () => {
test("rejects invalid cursors and missing sessions", async () => {
await using tmp = await tmpdir({ git: true })
await withoutWatcher(() =>
Instance.provide({
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -147,7 +148,7 @@ describe("session messages endpoint", () => {
test("does not truncate large legacy limit requests", async () => {
await using tmp = await tmpdir({ git: true })
await withoutWatcher(() =>
Instance.provide({
WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
@@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -30,7 +31,7 @@ afterEach(async () => {
describe("tui.selectSession endpoint", () => {
test("should return 200 when called with valid session", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// #given
@@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => {
test("should return 404 when session does not exist", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// #given
@@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => {
test("should return 400 when session ID format is invalid", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
// #given
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace"
import {
isLocalWorkspaceRoute,
getWorkspaceRouteSessionID,
workspaceProxyURL,
} from "../../src/server/shared/workspace-routing"
import { SessionID } from "../../src/session/schema"
describe("isLocalWorkspaceRoute", () => {
@@ -0,0 +1,148 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const stateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI,
KILO_EXPERIMENTAL_WORKSPACES: Flag.KILO_EXPERIMENTAL_WORKSPACES,
}
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_EXPERIMENTAL_WORKSPACES = original.KILO_EXPERIMENTAL_WORKSPACES
await resetDatabase()
}),
)
}),
)
const it = testEffect(stateLayer)
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
function serverScoped() {
return Effect.acquireRelease(
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
)
}
function request(server: TestServer, input: string, init?: RequestInit) {
return Effect.promise(() =>
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
)
}
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
}
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
return Effect.gen(function* () {
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
expect(current.status).toBe(200)
const project = (yield* Effect.promise(() => current.json())) as { id: string }
const updated = yield* request(
input.server,
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ commands: { start: input.command } }),
},
)
expect(updated.status).toBe(200)
})
}
describe("worktree endpoint reproduction", () => {
it.instance(
"direct HttpApi worktree create returns without waiting for boot",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* withRequestTimeout(
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
}),
"direct worktree create",
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
}),
{ git: true },
)
it.instance(
"workspace worktree create does not hang",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* withRequestTimeout(
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
}),
"workspace worktree create",
8_000,
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
type: "worktree",
directory: expect.any(String),
})
}),
{ git: true },
)
it.instance(
"workspace worktree create returns without waiting for project start command",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
yield* setProjectStartCommand({
server,
directory: test.directory,
command: 'bun -e "setTimeout(() => {}, 2000)"',
})
const started = Date.now()
const response = yield* withRequestTimeout(
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
}),
"workspace worktree create with project start command",
6_000,
)
expect(response.status).toBe(200)
expect(Date.now() - started).toBeLessThan(1_500)
}),
{ git: true },
)
})