+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
1018 lines
39 KiB
TypeScript
1018 lines
39 KiB
TypeScript
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
import { afterEach, describe, expect } from "bun:test"
|
|
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { mkdir } from "node:fs/promises"
|
|
import path from "node:path"
|
|
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
|
import { Workspace } from "../../src/control-plane/workspace"
|
|
|
|
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
|
|
import { InstanceStore } from "../../src/project/instance-store"
|
|
import { Project } from "../../src/project/project"
|
|
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 { Database } from "@opencode-ai/core/database/database"
|
|
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import * as DateTime from "effect/DateTime"
|
|
import { eq } from "drizzle-orm"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
|
import { TestLLMServer } from "../lib/llm-server"
|
|
import { testProviderConfig } from "../lib/test-provider"
|
|
import { pollWithTimeout, testEffect } from "../lib/effect"
|
|
|
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
|
const noopBootstrapLayer = Layer.succeed(
|
|
InstanceBootstrapService.Service,
|
|
InstanceBootstrapService.Service.of({ run: Effect.void }),
|
|
)
|
|
const appLayer = AppNodeBuilder.build(
|
|
LayerNode.group([InstanceStore.node, Project.node, Session.node, Workspace.node, Database.node, Ripgrep.node]),
|
|
[[InstanceStore.bootstrapNode, noopBootstrapLayer]],
|
|
)
|
|
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(appLayer, httpApiLayer))
|
|
|
|
function pathFor(path: string, params: Record<string, string>) {
|
|
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
|
}
|
|
|
|
function createSession(input?: Session.CreateInput) {
|
|
return Session.use.create(input)
|
|
}
|
|
|
|
function createTextMessage(sessionID: SessionIDType, text: string) {
|
|
return Effect.gen(function* () {
|
|
const svc = yield* Session.Service
|
|
const info = yield* svc.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID,
|
|
agent: "build",
|
|
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
|
|
time: { created: Date.now() },
|
|
})
|
|
const part = yield* svc.updatePart({
|
|
id: PartID.ascending(),
|
|
sessionID,
|
|
messageID: info.id,
|
|
type: "text",
|
|
text,
|
|
})
|
|
return { info, part }
|
|
})
|
|
}
|
|
|
|
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
|
name: "Local Test",
|
|
description: "Create a local test workspace",
|
|
configure: (info) => ({ ...info, name: "local-test", directory }),
|
|
create: async () => {
|
|
await mkdir(directory, { recursive: true })
|
|
},
|
|
async remove() {},
|
|
target: () => ({ type: "local" as const, directory }),
|
|
})
|
|
|
|
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
|
Effect.acquireRelease(
|
|
Effect.gen(function* () {
|
|
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
|
return yield* Workspace.Service.use((svc) =>
|
|
svc.create({
|
|
type: input.type,
|
|
branch: null,
|
|
extra: null,
|
|
projectID: input.projectID,
|
|
}),
|
|
)
|
|
}),
|
|
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
|
|
)
|
|
|
|
const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
|
|
Effect.gen(function* () {
|
|
const message = SessionMessage.Assistant.make({
|
|
id: SessionMessage.ID.create(),
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: {
|
|
id: ModelV2.ID.make("model"),
|
|
providerID: ProviderV2.ID.make("provider"),
|
|
variant: ModelV2.VariantID.make("default"),
|
|
},
|
|
time: { created: DateTime.makeUnsafe(time) },
|
|
content: [],
|
|
})
|
|
const { db } = yield* Database.Service
|
|
yield* db
|
|
.insert(SessionMessageTable)
|
|
.values([
|
|
{
|
|
id: message.id,
|
|
session_id: sessionID,
|
|
type: message.type,
|
|
seq,
|
|
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)
|
|
return message
|
|
})
|
|
|
|
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
|
Effect.gen(function* () {
|
|
const { db } = yield* Database.Service
|
|
yield* db
|
|
.insert(SessionMessageTable)
|
|
.values([
|
|
{
|
|
id: SessionMessage.ID.create(),
|
|
session_id: sessionID,
|
|
type: "assistant",
|
|
seq: time,
|
|
time_created: time,
|
|
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
|
},
|
|
])
|
|
.run()
|
|
.pipe(Effect.orDie)
|
|
})
|
|
|
|
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
|
|
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.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.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) {
|
|
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: 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: HttpClientResponse.HttpClientResponse) {
|
|
return response.json
|
|
}
|
|
|
|
function requestJson<T>(path: string, init?: RequestInit) {
|
|
return request(path, init).pipe(Effect.flatMap(json<T>))
|
|
}
|
|
|
|
afterEach(async () => {
|
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
|
await disposeAllInstances()
|
|
await resetDatabase()
|
|
})
|
|
|
|
describe("session HttpApi", () => {
|
|
it.effect("maps busy sessions to public session busy errors", () =>
|
|
Effect.gen(function* () {
|
|
const sessionID = SessionID.descending()
|
|
const exit = yield* HttpSessionError.mapBusy(Effect.fail(new Session.BusyError({ sessionID }))).pipe(Effect.exit)
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) {
|
|
expect(Cause.squash(exit.cause)).toMatchObject({
|
|
_tag: "SessionBusyError",
|
|
sessionID,
|
|
message: `Session is busy: ${sessionID}`,
|
|
})
|
|
}
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"returns declared not found errors for read routes",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const missingSession = SessionID.descending()
|
|
const missingSessionBody = {
|
|
name: "NotFoundError",
|
|
data: { message: `Session not found: ${missingSession}` },
|
|
}
|
|
|
|
const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
|
|
expect(get.status).toBe(404)
|
|
expect(yield* responseJson(get)).toEqual(missingSessionBody)
|
|
|
|
const children = yield* request(pathFor(SessionPaths.children, { sessionID: missingSession }), { headers })
|
|
expect(children.status).toBe(404)
|
|
expect(yield* responseJson(children)).toEqual(missingSessionBody)
|
|
|
|
const todo = yield* request(pathFor(SessionPaths.todo, { sessionID: missingSession }), { headers })
|
|
expect(todo.status).toBe(404)
|
|
expect(yield* responseJson(todo)).toEqual(missingSessionBody)
|
|
|
|
const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
|
|
expect(messages.status).toBe(404)
|
|
expect(yield* responseJson(messages)).toEqual(missingSessionBody)
|
|
|
|
const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
|
|
headers,
|
|
method: "DELETE",
|
|
})
|
|
expect(remove.status).toBe(404)
|
|
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
|
|
|
|
const prompt = yield* request(pathFor(SessionPaths.prompt, { sessionID: missingSession }), {
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
method: "POST",
|
|
body: JSON.stringify({ agent: "build", noReply: true, parts: [{ type: "text", text: "hello" }] }),
|
|
})
|
|
expect(prompt.status).toBe(404)
|
|
expect(yield* responseJson(prompt)).toEqual(missingSessionBody)
|
|
|
|
const abort = yield* request(pathFor(SessionPaths.abort, { sessionID: missingSession }), {
|
|
headers,
|
|
method: "POST",
|
|
})
|
|
expect(abort.status).toBe(200)
|
|
expect(yield* responseJson(abort)).toBe(true)
|
|
|
|
const session = yield* createSession({ title: "missing message" })
|
|
const missingMessage = MessageID.ascending()
|
|
const message = yield* request(
|
|
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
|
|
{ headers },
|
|
)
|
|
expect(message.status).toBe(404)
|
|
expect(yield* responseJson(message)).toEqual({
|
|
name: "NotFoundError",
|
|
data: { message: `Message not found: ${missingMessage}` },
|
|
})
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves read routes",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const parent = yield* createSession({ title: "parent" })
|
|
const child = yield* createSession({ title: "child", parentID: parent.id })
|
|
const message = yield* createTextMessage(parent.id, "hello")
|
|
yield* createTextMessage(parent.id, "world")
|
|
|
|
const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?roots=true`, { headers })
|
|
expect(listed.map((item) => item.id)).toContain(parent.id)
|
|
expect(Object.hasOwn(listed[0]!, "parentID")).toBe(false)
|
|
|
|
expect(yield* requestJson<Record<string, unknown>>(SessionPaths.status, { headers })).toEqual({})
|
|
|
|
expect(
|
|
yield* requestJson<Session.Info>(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }),
|
|
).toMatchObject({ id: parent.id, title: "parent" })
|
|
|
|
expect(
|
|
(yield* requestJson<Session.Info[]>(pathFor(SessionPaths.children, { sessionID: parent.id }), {
|
|
headers,
|
|
})).map((item) => item.id),
|
|
).toEqual([child.id])
|
|
|
|
expect(
|
|
yield* requestJson<unknown[]>(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }),
|
|
).toEqual([])
|
|
|
|
expect(
|
|
yield* requestJson<unknown[]>(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }),
|
|
).toEqual([])
|
|
|
|
const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
|
|
headers,
|
|
})
|
|
const messagePage = yield* json<SessionV1.WithParts[]>(messages)
|
|
const nextCursor = messages.headers["x-next-cursor"]
|
|
expect(nextCursor).toBeTruthy()
|
|
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
|
|
|
|
expect(
|
|
(yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
|
|
headers,
|
|
})).status,
|
|
).toBe(400)
|
|
expect(
|
|
(yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
|
|
headers,
|
|
})).status,
|
|
).toBe(400)
|
|
|
|
expect(
|
|
yield* requestJson<SessionV1.WithParts>(
|
|
pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
|
|
{ headers },
|
|
),
|
|
).toMatchObject({ info: { id: message.info.id } })
|
|
|
|
yield* insertLegacyAssistantMessage(parent.id)
|
|
|
|
expect(
|
|
(yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
|
|
headers,
|
|
})).data,
|
|
).toMatchObject([{ type: "assistant" }])
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.live("uses the persisted session directory for prompt requests", () =>
|
|
Effect.gen(function* () {
|
|
const llm = yield* TestLLMServer
|
|
yield* llm.text("ok", { usage: { input: 1, output: 1 } })
|
|
|
|
const config = testProviderConfig(llm.url)
|
|
const sessionDirectory = yield* tmpdirScoped({ git: true, config })
|
|
const requestDirectory = yield* tmpdirScoped({ git: true, config })
|
|
const session = yield* createSession({ title: "directory regression" }).pipe(
|
|
provideInstanceEffect(sessionDirectory),
|
|
)
|
|
|
|
const response = yield* request(
|
|
`${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
agent: "build",
|
|
model: { providerID: "test", modelID: "test-model" },
|
|
parts: [{ type: "text", text: "which directory?" }],
|
|
}),
|
|
},
|
|
)
|
|
|
|
expect(response.status).toBe(200)
|
|
yield* responseJson(response)
|
|
|
|
const messages = yield* Session.use
|
|
.messages({ sessionID: session.id })
|
|
.pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
|
|
const assistant = messages.find((message) => message.info.role === "assistant")
|
|
expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
|
|
cwd: sessionDirectory,
|
|
root: sessionDirectory,
|
|
})
|
|
}).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))),
|
|
)
|
|
|
|
it.instance(
|
|
"returns v2 public request errors for cursor and workspace query failures",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const session = yield* createSession({ title: "v2 cursor" })
|
|
const firstMessage = yield* insertLegacyAssistantMessage(session.id, 1, 2)
|
|
const secondMessage = yield* insertLegacyAssistantMessage(session.id, 2, 1)
|
|
|
|
const sessionPage = yield* request(
|
|
`/api/session?${new URLSearchParams({
|
|
limit: "1",
|
|
order: "asc",
|
|
directory: test.directory,
|
|
search: "v2",
|
|
})}`,
|
|
{ headers },
|
|
)
|
|
const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor
|
|
.next
|
|
expect(sessionCursor).toBeTruthy()
|
|
expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
|
|
order: "asc",
|
|
directory: test.directory,
|
|
search: "v2",
|
|
anchor: { id: session.id, direction: "next" },
|
|
})
|
|
|
|
const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers })
|
|
expect(sessionNextPage.status).toBe(200)
|
|
|
|
const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
|
|
expect(invalidSessionCursor.status).toBe(400)
|
|
expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
|
|
_tag: "InvalidCursorError",
|
|
message: "Invalid cursor",
|
|
})
|
|
|
|
const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
|
|
expect(invalidWorkspace.status).toBe(400)
|
|
expect(yield* responseJson(invalidWorkspace)).toMatchObject({
|
|
_tag: "InvalidRequestError",
|
|
kind: "Query",
|
|
})
|
|
|
|
const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
|
|
const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
|
|
const messageCursor = messageBody.cursor.next
|
|
expect(messageCursor).toBeTruthy()
|
|
expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
|
|
expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
|
|
id: secondMessage.id,
|
|
order: "desc",
|
|
direction: "next",
|
|
})
|
|
|
|
const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, {
|
|
headers,
|
|
})
|
|
expect(
|
|
(yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
|
|
).toEqual([firstMessage.id])
|
|
|
|
const legacyMessageCursor = Buffer.from(
|
|
JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }),
|
|
).toString("base64url")
|
|
const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
|
|
headers,
|
|
})
|
|
expect(
|
|
(yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
|
|
).toEqual([firstMessage.id])
|
|
|
|
const messageCursorWithOrder = yield* request(
|
|
`/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
|
|
{ headers },
|
|
)
|
|
expect(messageCursorWithOrder.status).toBe(400)
|
|
expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({
|
|
_tag: "InvalidCursorError",
|
|
message: "Cursor cannot be combined with order",
|
|
})
|
|
|
|
const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers })
|
|
expect(invalidMessageCursor.status).toBe(400)
|
|
expect(yield* responseJson(invalidMessageCursor)).toMatchObject({
|
|
_tag: "InvalidCursorError",
|
|
message: "Invalid cursor",
|
|
})
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"returns v2 public not found errors for missing sessions",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const missing = SessionID.descending()
|
|
const expected = {
|
|
_tag: "SessionNotFoundError",
|
|
sessionID: missing,
|
|
message: `Session not found: ${missing}`,
|
|
}
|
|
|
|
const messages = yield* request(`/api/session/${missing}/message`, { headers })
|
|
expect(messages.status).toBe(404)
|
|
expect(yield* responseJson(messages)).toEqual(expected)
|
|
|
|
const context = yield* request(`/api/session/${missing}/context`, { headers })
|
|
expect(context.status).toBe(404)
|
|
expect(yield* responseJson(context)).toEqual(expected)
|
|
|
|
const compact = yield* request(`/api/session/${missing}/compact`, { method: "POST", headers })
|
|
expect(compact.status).toBe(404)
|
|
expect(yield* responseJson(compact)).toEqual(expected)
|
|
|
|
const wait = yield* request(`/api/session/${missing}/wait`, { method: "POST", headers })
|
|
expect(wait.status).toBe(404)
|
|
expect(yield* responseJson(wait)).toEqual(expected)
|
|
|
|
const prompt = yield* request(`/api/session/${missing}/prompt`, {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({ prompt: { text: "hello" } }),
|
|
})
|
|
expect(prompt.status).toBe(404)
|
|
expect(yield* responseJson(prompt)).toEqual(expected)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"durably records one v2 prompt for exact message-ID retries",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const session = yield* createSession({ title: "v2 prompt recording" })
|
|
|
|
const recordPrompt = () =>
|
|
request(`/api/session/${session.id}/prompt`, {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }),
|
|
})
|
|
const first = yield* recordPrompt()
|
|
const retried = yield* recordPrompt()
|
|
type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
|
|
const firstBody = yield* json<{ data: PromptBody }>(first)
|
|
const retriedBody = yield* json<{ data: PromptBody }>(retried)
|
|
expect(first.status).toBe(200)
|
|
expect(retried.status).toBe(200)
|
|
expect(retriedBody).toEqual(firstBody)
|
|
expect(firstBody).toMatchObject({
|
|
data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
|
|
})
|
|
|
|
const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
|
|
headers,
|
|
})
|
|
expect(messages.data).toHaveLength(0)
|
|
const admitted = yield* Database.Service.use(({ db }) =>
|
|
db
|
|
.select()
|
|
.from(SessionInputTable)
|
|
.where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
|
|
.get()
|
|
.pipe(Effect.orDie),
|
|
)
|
|
expect(admitted).toMatchObject({
|
|
id: "msg_http_prompt",
|
|
session_id: session.id,
|
|
delivery: "steer",
|
|
promoted_seq: null,
|
|
})
|
|
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
|
|
})
|
|
expect(conflict.status).toBe(409)
|
|
expect(yield* responseJson(conflict)).toEqual({
|
|
_tag: "ConflictError",
|
|
message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
|
|
resource: "msg_http_prompt",
|
|
})
|
|
|
|
const wakeID = SessionMessage.ID.make("msg_http_wake")
|
|
const wake = yield* request(`/api/session/${session.id}/prompt`, {
|
|
method: "POST",
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }),
|
|
})
|
|
expect(wake.status).toBe(200)
|
|
const message = yield* pollWithTimeout(
|
|
requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe(
|
|
Effect.map(({ data }) => data.find((message) => message.id === wakeID)),
|
|
),
|
|
"V2 prompt was not promoted after wake",
|
|
"10 seconds",
|
|
)
|
|
expect(message).toMatchObject({ id: wakeID, type: "user" })
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"returns v2 public unavailable errors for unfinished session mutations",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const session = yield* createSession({ title: "v2 unavailable" })
|
|
|
|
const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
|
|
expect(compact.status).toBe(503)
|
|
expect(yield* responseJson(compact)).toEqual({
|
|
_tag: "ServiceUnavailableError",
|
|
message: "Session compact is not available yet",
|
|
service: "session.compact",
|
|
})
|
|
|
|
const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers })
|
|
expect(wait.status).toBe(503)
|
|
expect(yield* responseJson(wait)).toEqual({
|
|
_tag: "ServiceUnavailableError",
|
|
message: "Session wait is not available yet",
|
|
service: "session.wait",
|
|
})
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"returns safe v2 unknown errors for corrupt projected messages",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const session = yield* createSession({ title: "v2 corrupt message" })
|
|
yield* insertCorruptV2Message(session.id)
|
|
|
|
const messages = yield* request(`/api/session/${session.id}/message`, {
|
|
headers: { "x-opencode-directory": test.directory },
|
|
})
|
|
const messagesBody = yield* responseJson(messages)
|
|
expect(messages.status).toBe(500)
|
|
expect(messagesBody).toMatchObject({
|
|
_tag: "UnknownError",
|
|
message: "Unexpected server error. Check server logs for details.",
|
|
})
|
|
expect((messagesBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
|
|
expect(JSON.stringify(messagesBody)).not.toContain("assistant")
|
|
|
|
const context = yield* request(`/api/session/${session.id}/context`, {
|
|
headers: { "x-opencode-directory": test.directory },
|
|
})
|
|
const contextBody = yield* responseJson(context)
|
|
expect(context.status).toBe(500)
|
|
expect(contextBody).toMatchObject({
|
|
_tag: "UnknownError",
|
|
message: "Unexpected server error. Check server logs for details.",
|
|
})
|
|
expect((contextBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
|
|
expect(JSON.stringify(contextBody)).not.toContain("assistant")
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves sessions with migrated summary diffs missing file details",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const session = yield* createSession({ title: "legacy diff" })
|
|
yield* setLegacySummaryDiff(session.id)
|
|
|
|
const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), {
|
|
headers: { "x-opencode-directory": test.directory },
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect((yield* json<Session.Info>(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }])
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves lifecycle mutation routes",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
|
|
|
const createdEmpty = yield* requestJson<Session.Info>(SessionPaths.create, {
|
|
method: "POST",
|
|
headers,
|
|
})
|
|
expect(createdEmpty.id).toBeTruthy()
|
|
|
|
const created = yield* requestJson<Session.Info>(SessionPaths.create, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ title: "created" }),
|
|
})
|
|
expect(created.title).toBe("created")
|
|
|
|
const updated = yield* requestJson<Session.Info>(pathFor(SessionPaths.update, { sessionID: created.id }), {
|
|
method: "PATCH",
|
|
headers,
|
|
body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
|
|
})
|
|
expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
|
|
|
|
const forked = yield* requestJson<Session.Info>(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
|
method: "POST",
|
|
headers,
|
|
})
|
|
expect(forked.id).not.toBe(created.id)
|
|
|
|
const forkedWithoutContentType = yield* requestJson<Session.Info>(
|
|
pathFor(SessionPaths.fork, { sessionID: created.id }),
|
|
{
|
|
method: "POST",
|
|
headers: { "x-opencode-directory": test.directory },
|
|
},
|
|
)
|
|
expect(forkedWithoutContentType.id).not.toBe(created.id)
|
|
|
|
const invalidFork = yield* request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
|
method: "POST",
|
|
headers,
|
|
body: "{",
|
|
})
|
|
expect(invalidFork.status).toBe(400)
|
|
|
|
const forkedWhitespace = yield* requestJson<Session.Info>(
|
|
pathFor(SessionPaths.fork, { sessionID: created.id }),
|
|
{
|
|
method: "POST",
|
|
headers,
|
|
body: " \n",
|
|
},
|
|
)
|
|
expect(forkedWhitespace.id).not.toBe(created.id)
|
|
|
|
expect(
|
|
yield* requestJson<boolean>(pathFor(SessionPaths.abort, { sessionID: created.id }), {
|
|
method: "POST",
|
|
headers,
|
|
}),
|
|
).toBe(true)
|
|
|
|
expect(
|
|
yield* requestJson<boolean>(pathFor(SessionPaths.remove, { sessionID: created.id }), {
|
|
method: "DELETE",
|
|
headers,
|
|
}),
|
|
).toBe(true)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
|
|
)
|
|
|
|
it.instance(
|
|
"persists selected workspace id when creating a session",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
|
const project = yield* Project.use.fromDirectory(test.directory)
|
|
const workspace = yield* createLocalWorkspace({
|
|
projectID: project.project.id,
|
|
type: "session-create-workspace",
|
|
directory: path.join(test.directory, ".workspace-local"),
|
|
})
|
|
|
|
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
|
|
method: "POST",
|
|
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
|
body: JSON.stringify({ title: "workspace session" }),
|
|
})
|
|
const messages = yield* request(
|
|
`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`,
|
|
{
|
|
headers: { "x-opencode-directory": test.directory },
|
|
},
|
|
)
|
|
|
|
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
|
|
expect(messages.status).toBe(200)
|
|
expect(yield* getWorkspaceID(created.id)).toEqual({ workspaceID: workspace.id })
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
|
|
)
|
|
|
|
it.instance(
|
|
"validates archived timestamp values",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
|
const session = yield* createSession({ title: "archived" })
|
|
const body = JSON.stringify({ time: { archived: -1 } })
|
|
|
|
const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), {
|
|
method: "PATCH",
|
|
headers,
|
|
body,
|
|
})
|
|
expect(response.status).toBe(200)
|
|
expect((yield* json<Session.Info>(response)).time.archived).toBe(-1)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"uses project-scoped path and directory precedence",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const currentDir = path.join(test.directory, "packages", "opencode", "src")
|
|
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
|
|
|
|
const store = yield* InstanceStore.Service
|
|
const { pathSession, pathlessSession } = yield* store.provide(
|
|
{ directory: currentDir },
|
|
Effect.gen(function* () {
|
|
return {
|
|
pathSession: yield* createSession(),
|
|
pathlessSession: yield* createSession(),
|
|
}
|
|
}).pipe(Effect.provideService(TestInstance, { directory: currentDir })),
|
|
)
|
|
yield* clearSessionPath(pathlessSession.id)
|
|
|
|
const query = new URLSearchParams({
|
|
scope: "project",
|
|
path: "packages/opencode/src",
|
|
directory: currentDir,
|
|
})
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const sessions = (yield* json<Session.Info[]>(
|
|
yield* request(`${SessionPaths.list}?${query}`, { headers }),
|
|
)).map((item) => item.id)
|
|
|
|
expect(sessions).toContain(pathSession.id)
|
|
expect(sessions).not.toContain(pathlessSession.id)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves paginated message link headers",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory }
|
|
const session = yield* createSession({ title: "messages" })
|
|
yield* createTextMessage(session.id, "first")
|
|
yield* createTextMessage(session.id, "second")
|
|
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
|
|
|
|
const response = yield* request(route, { headers })
|
|
|
|
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 } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves message mutation routes",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
|
const session = yield* createSession({ title: "messages" })
|
|
const first = yield* createTextMessage(session.id, "first")
|
|
const second = yield* createTextMessage(session.id, "second")
|
|
|
|
const updated = yield* requestJson<SessionV1.Part>(
|
|
pathFor(SessionPaths.updatePart, {
|
|
sessionID: session.id,
|
|
messageID: first.info.id,
|
|
partID: first.part.id,
|
|
}),
|
|
{
|
|
method: "PATCH",
|
|
headers,
|
|
body: JSON.stringify({ ...first.part, text: "updated" }),
|
|
},
|
|
)
|
|
expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
|
|
|
|
expect(
|
|
yield* requestJson<boolean>(
|
|
pathFor(SessionPaths.deletePart, {
|
|
sessionID: session.id,
|
|
messageID: first.info.id,
|
|
partID: first.part.id,
|
|
}),
|
|
{ method: "DELETE", headers },
|
|
),
|
|
).toBe(true)
|
|
|
|
expect(
|
|
yield* requestJson<boolean>(
|
|
pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }),
|
|
{ method: "DELETE", headers },
|
|
),
|
|
).toBe(true)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"rejects part updates whose path and body ids disagree",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
|
const session = yield* createSession({ title: "part mismatch" })
|
|
const message = yield* createTextMessage(session.id, "first")
|
|
const response = yield* request(
|
|
pathFor(SessionPaths.updatePart, {
|
|
sessionID: session.id,
|
|
messageID: message.info.id,
|
|
partID: message.part.id,
|
|
}),
|
|
{
|
|
method: "PATCH",
|
|
headers,
|
|
body: JSON.stringify({ ...message.part, id: PartID.ascending() }),
|
|
},
|
|
)
|
|
|
|
expect(response.status).toBe(400)
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
|
|
it.instance(
|
|
"serves remaining non-LLM session mutation routes",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
|
const session = yield* createSession({ title: "remaining" })
|
|
|
|
expect(
|
|
yield* requestJson<Session.Info>(pathFor(SessionPaths.revert, { sessionID: session.id }), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ messageID: MessageID.ascending() }),
|
|
}),
|
|
).toMatchObject({ id: session.id })
|
|
|
|
expect(
|
|
yield* requestJson<Session.Info>(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
|
|
method: "POST",
|
|
headers,
|
|
}),
|
|
).toMatchObject({ id: session.id })
|
|
|
|
const permissionID = String(PermissionV1.ID.ascending())
|
|
const permission = yield* request(
|
|
pathFor(SessionPaths.permissions, {
|
|
sessionID: session.id,
|
|
permissionID,
|
|
}),
|
|
{
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ response: "once" }),
|
|
},
|
|
)
|
|
expect(permission.status).toBe(404)
|
|
expect(yield* responseJson(permission)).toEqual({
|
|
_tag: "PermissionNotFoundError",
|
|
requestID: permissionID,
|
|
message: `Permission request not found: ${permissionID}`,
|
|
})
|
|
}),
|
|
{ git: true, config: { formatter: false, lsp: false } },
|
|
)
|
|
})
|