2026-04-27 21:38:28 +00:00
|
|
|
import { afterEach, describe, expect } from "bun:test"
|
2026-04-30 15:07:00 +00:00
|
|
|
import { mkdir } from "node:fs/promises"
|
|
|
|
|
import path from "node:path"
|
2026-05-07 14:24:17 +00:00
|
|
|
import { Effect, Layer } from "effect"
|
2026-04-26 15:49:11 +00:00
|
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
2026-05-01 11:36:52 +00:00
|
|
|
import { registerAdapter } from "../../src/control-plane/adapters"
|
|
|
|
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
2026-04-30 17:53:26 +00:00
|
|
|
import { Workspace } from "../../src/control-plane/workspace"
|
2026-04-26 16:24:19 +00:00
|
|
|
import { PermissionID } from "../../src/permission/schema"
|
2026-04-26 15:49:11 +00:00
|
|
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
2026-05-03 00:39:20 +00:00
|
|
|
import { WithInstance } from "../../src/project/with-instance"
|
2026-05-07 14:24:17 +00:00
|
|
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
|
|
|
|
import { InstanceStore } from "../../src/project/instance-store"
|
2026-04-30 17:53:26 +00:00
|
|
|
import { Project } from "../../src/project/project"
|
2026-04-28 15:02:35 +00:00
|
|
|
import { Server } from "../../src/server/server"
|
2026-04-29 13:34:50 +00:00
|
|
|
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
2026-04-27 18:33:33 +00:00
|
|
|
import { Session } from "@/session/session"
|
2026-05-06 01:33:47 +00:00
|
|
|
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
2026-04-26 15:49:11 +00:00
|
|
|
import { MessageV2 } from "../../src/session/message-v2"
|
2026-04-30 15:07:00 +00:00
|
|
|
import { Database } from "@/storage/db"
|
2026-05-03 02:09:48 +00:00
|
|
|
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
|
|
|
|
|
import { SessionMessage } from "../../src/v2/session-message"
|
2026-05-05 02:35:21 +00:00
|
|
|
import { Modelv2 } from "../../src/v2/model"
|
2026-05-03 02:09:48 +00:00
|
|
|
import * as DateTime from "effect/DateTime"
|
2026-04-27 18:33:33 +00:00
|
|
|
import * as Log from "@opencode-ai/core/util/log"
|
2026-04-30 15:07:00 +00:00
|
|
|
import { eq } from "drizzle-orm"
|
2026-04-26 15:49:11 +00:00
|
|
|
import { resetDatabase } from "../fixture/db"
|
2026-05-02 14:56:15 +00:00
|
|
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
2026-04-27 21:38:28 +00:00
|
|
|
import { it } from "../lib/effect"
|
2026-04-26 15:49:11 +00:00
|
|
|
|
|
|
|
|
void Log.init({ print: false })
|
|
|
|
|
|
2026-04-30 17:53:26 +00:00
|
|
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
2026-05-07 14:24:17 +00:00
|
|
|
const workspaceLayer = Workspace.defaultLayer.pipe(
|
|
|
|
|
Layer.provide(InstanceStore.defaultLayer),
|
|
|
|
|
Layer.provide(InstanceBootstrap.defaultLayer),
|
|
|
|
|
)
|
2026-04-26 15:49:11 +00:00
|
|
|
|
2026-05-09 13:10:42 +00:00
|
|
|
function app() {
|
|
|
|
|
return Server.Default().app
|
2026-04-26 15:49:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
|
|
|
|
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pathFor(path: string, params: Record<string, string>) {
|
|
|
|
|
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
function createSession(directory: string, input?: Session.CreateInput) {
|
|
|
|
|
return Effect.promise(
|
|
|
|
|
async () =>
|
2026-05-03 00:39:20 +00:00
|
|
|
await WithInstance.provide({
|
2026-04-27 21:38:28 +00:00
|
|
|
directory,
|
|
|
|
|
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 01:33:47 +00:00
|
|
|
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
|
2026-04-27 21:38:28 +00:00
|
|
|
return Effect.promise(
|
|
|
|
|
async () =>
|
2026-05-03 00:39:20 +00:00
|
|
|
await WithInstance.provide({
|
2026-04-27 21:38:28 +00:00
|
|
|
directory,
|
|
|
|
|
fn: () =>
|
|
|
|
|
runSession(
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const svc = yield* Session.Service
|
|
|
|
|
const info = yield* svc.updateMessage({
|
|
|
|
|
id: MessageID.ascending(),
|
|
|
|
|
role: "user",
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: "build",
|
|
|
|
|
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
|
|
|
time: { created: Date.now() },
|
|
|
|
|
})
|
|
|
|
|
const part = yield* svc.updatePart({
|
|
|
|
|
id: PartID.ascending(),
|
|
|
|
|
sessionID,
|
|
|
|
|
messageID: info.id,
|
|
|
|
|
type: "text",
|
|
|
|
|
text,
|
|
|
|
|
})
|
|
|
|
|
return { info, part }
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 11:36:52 +00:00
|
|
|
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
2026-04-30 17:53:26 +00:00
|
|
|
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 }) =>
|
2026-04-30 19:34:37 +00:00
|
|
|
Effect.gen(function* () {
|
2026-05-01 11:36:52 +00:00
|
|
|
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
2026-04-30 19:34:37 +00:00
|
|
|
return yield* Workspace.Service.use((svc) =>
|
|
|
|
|
svc.create({
|
|
|
|
|
type: input.type,
|
|
|
|
|
branch: null,
|
|
|
|
|
extra: null,
|
|
|
|
|
projectID: input.projectID,
|
|
|
|
|
}),
|
2026-05-07 14:24:17 +00:00
|
|
|
).pipe(Effect.provide(workspaceLayer))
|
2026-04-30 17:53:26 +00:00
|
|
|
})
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
function request(path: string, init?: RequestInit) {
|
|
|
|
|
return Effect.promise(async () => app().request(path, init))
|
2026-04-26 15:49:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
function json<T>(response: Response) {
|
|
|
|
|
return Effect.promise(async () => {
|
|
|
|
|
if (response.status !== 200) throw new Error(await response.text())
|
|
|
|
|
return (await response.json()) as T
|
2026-04-26 15:49:11 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 01:33:47 +00:00
|
|
|
function responseJson(response: Response) {
|
|
|
|
|
return Effect.promise(() => response.json())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
function requestJson<T>(path: string, init?: RequestInit) {
|
|
|
|
|
return request(path, init).pipe(Effect.flatMap(json<T>))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withTmp<A, E, R>(
|
|
|
|
|
options: Parameters<typeof tmpdir>[0],
|
|
|
|
|
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
|
|
|
|
) {
|
|
|
|
|
return Effect.acquireRelease(
|
|
|
|
|
Effect.promise(() => tmpdir(options)),
|
|
|
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
|
|
|
).pipe(Effect.flatMap(fn))
|
2026-04-26 15:49:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
2026-04-30 17:53:26 +00:00
|
|
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
2026-05-02 14:56:15 +00:00
|
|
|
await disposeAllInstances()
|
2026-04-26 15:49:11 +00:00
|
|
|
await resetDatabase()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe("session HttpApi", () => {
|
2026-05-06 01:33:47 +00:00
|
|
|
it.live(
|
|
|
|
|
"returns declared not found errors for read routes",
|
|
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
|
|
|
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 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 session = yield* createSession(tmp.path, { 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}` },
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"serves read routes",
|
2026-04-27 21:38:28 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
|
|
|
const parent = yield* createSession(tmp.path, { title: "parent" })
|
|
|
|
|
const child = yield* createSession(tmp.path, { title: "child", parentID: parent.id })
|
|
|
|
|
const message = yield* createTextMessage(tmp.path, parent.id, "hello")
|
|
|
|
|
yield* createTextMessage(tmp.path, 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`, {
|
2026-04-26 16:24:19 +00:00
|
|
|
headers,
|
|
|
|
|
})
|
2026-04-27 21:38:28 +00:00
|
|
|
const messagePage = yield* json<MessageV2.WithParts[]>(messages)
|
|
|
|
|
const nextCursor = messages.headers.get("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<MessageV2.WithParts>(
|
|
|
|
|
pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
|
|
|
|
|
{ headers },
|
|
|
|
|
),
|
|
|
|
|
).toMatchObject({ info: { id: message.info.id } })
|
2026-05-03 02:09:48 +00:00
|
|
|
|
|
|
|
|
yield* Effect.promise(() =>
|
|
|
|
|
WithInstance.provide({
|
|
|
|
|
directory: tmp.path,
|
|
|
|
|
fn: async () => {
|
|
|
|
|
const message = new SessionMessage.Assistant({
|
|
|
|
|
id: SessionMessage.ID.create(),
|
|
|
|
|
type: "assistant",
|
|
|
|
|
agent: "build",
|
2026-05-05 02:35:21 +00:00
|
|
|
model: {
|
|
|
|
|
id: Modelv2.ID.make("model"),
|
|
|
|
|
providerID: Modelv2.ProviderID.make("provider"),
|
|
|
|
|
variant: Modelv2.VariantID.make("default"),
|
|
|
|
|
},
|
2026-05-03 02:09:48 +00:00
|
|
|
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(
|
fix(httpapi): eliminate drift between runtime query schemas and OpenAPI params
Context: PR #26569 narrowly fixed a crash where the generated SDK sent
GET /session/{sessionID}/message?limit=80&directory=... because public.ts
manually injected InstanceQueryParameters (directory/workspace) into OpenAPI,
but the runtime MessagesQuery schema omitted directory, causing empty 400.
This change eliminates the drift by:
1. Creating a shared schema helper in query.ts that adds directory/workspace
fields to all instance route query schemas.
2. Updating all instance route query schemas to use the helper:
- session.ts: MessagesQuery, ListQuery
- file.ts: FileQuery, FindTextQuery, FindFileQuery, FindSymbolQuery
- experimental.ts: ToolListQuery, SessionListQuery
- control.ts: LogQuery (already correct, now uses helper)
- instance.ts, v2/session.ts, v2/message.ts
3. Adding reproducer tests in httpapi-query-schema-drift.test.ts that verify
the runtime accepts directory/workspace params on affected routes.
The OpenAPI spec generation in public.ts still manually injects params for
backward compatibility with the legacy SDK format, but now the runtime
schemas match, eliminating the validation errors.
Verification:
- bun typecheck passes
- 4 drift reproducer tests pass
- 24 httpapi tests pass across session, file, experimental, workspace-routing
2026-05-09 20:12:02 +00:00
|
|
|
(yield* requestJson<{ items: SessionMessage.Message[] }>(
|
|
|
|
|
`/api/session/${parent.id}/message?directory=${encodeURIComponent(tmp.path)}`,
|
|
|
|
|
{ headers },
|
|
|
|
|
)).items,
|
2026-05-03 02:09:48 +00:00
|
|
|
).toMatchObject([{ type: "assistant" }])
|
2026-04-27 21:38:28 +00:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"serves lifecycle mutation routes",
|
2026-04-27 21:38:28 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
|
|
|
|
|
|
|
|
|
const createdEmpty = yield* requestJson<Session.Info>(SessionPaths.create, {
|
|
|
|
|
method: "POST",
|
2026-04-26 16:24:19 +00:00
|
|
|
headers,
|
|
|
|
|
})
|
2026-04-27 21:38:28 +00:00
|
|
|
expect(createdEmpty.id).toBeTruthy()
|
2026-04-26 16:24:19 +00:00
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
const created = yield* requestJson<Session.Info>(SessionPaths.create, {
|
|
|
|
|
method: "POST",
|
2026-04-26 15:50:09 +00:00
|
|
|
headers,
|
2026-04-27 21:38:28 +00:00
|
|
|
body: JSON.stringify({ title: "created" }),
|
|
|
|
|
})
|
|
|
|
|
expect(created.title).toBe("created")
|
2026-04-26 16:00:02 +00:00
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
const updated = yield* requestJson<Session.Info>(pathFor(SessionPaths.update, { sessionID: created.id }), {
|
2026-04-26 16:00:02 +00:00
|
|
|
method: "PATCH",
|
|
|
|
|
headers,
|
2026-04-27 21:38:28 +00:00
|
|
|
body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
|
|
|
|
|
})
|
|
|
|
|
expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
|
2026-04-26 16:24:19 +00:00
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
const forked = yield* requestJson<Session.Info>(pathFor(SessionPaths.fork, { sessionID: created.id }), {
|
2026-04-26 16:24:19 +00:00
|
|
|
method: "POST",
|
|
|
|
|
headers,
|
2026-04-27 21:38:28 +00:00
|
|
|
body: JSON.stringify({}),
|
|
|
|
|
})
|
|
|
|
|
expect(forked.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)
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-30 17:53:26 +00:00
|
|
|
it.live(
|
|
|
|
|
"persists selected workspace id when creating a session",
|
|
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
|
|
|
|
const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer))
|
|
|
|
|
const workspace = yield* createLocalWorkspace({
|
|
|
|
|
projectID: project.project.id,
|
|
|
|
|
type: "session-create-workspace",
|
|
|
|
|
directory: path.join(tmp.path, ".workspace-local"),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ title: "workspace session" }),
|
|
|
|
|
})
|
fix(httpapi): eliminate drift between runtime query schemas and OpenAPI params
Context: PR #26569 narrowly fixed a crash where the generated SDK sent
GET /session/{sessionID}/message?limit=80&directory=... because public.ts
manually injected InstanceQueryParameters (directory/workspace) into OpenAPI,
but the runtime MessagesQuery schema omitted directory, causing empty 400.
This change eliminates the drift by:
1. Creating a shared schema helper in query.ts that adds directory/workspace
fields to all instance route query schemas.
2. Updating all instance route query schemas to use the helper:
- session.ts: MessagesQuery, ListQuery
- file.ts: FileQuery, FindTextQuery, FindFileQuery, FindSymbolQuery
- experimental.ts: ToolListQuery, SessionListQuery
- control.ts: LogQuery (already correct, now uses helper)
- instance.ts, v2/session.ts, v2/message.ts
3. Adding reproducer tests in httpapi-query-schema-drift.test.ts that verify
the runtime accepts directory/workspace params on affected routes.
The OpenAPI spec generation in public.ts still manually injects params for
backward compatibility with the legacy SDK format, but now the runtime
schemas match, eliminating the validation errors.
Verification:
- bun typecheck passes
- 4 drift reproducer tests pass
- 24 httpapi tests pass across session, file, experimental, workspace-routing
2026-05-09 20:12:02 +00:00
|
|
|
const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, {
|
|
|
|
|
headers: { "x-opencode-directory": tmp.path },
|
|
|
|
|
})
|
2026-04-30 17:53:26 +00:00
|
|
|
|
|
|
|
|
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
|
fix(httpapi): eliminate drift between runtime query schemas and OpenAPI params
Context: PR #26569 narrowly fixed a crash where the generated SDK sent
GET /session/{sessionID}/message?limit=80&directory=... because public.ts
manually injected InstanceQueryParameters (directory/workspace) into OpenAPI,
but the runtime MessagesQuery schema omitted directory, causing empty 400.
This change eliminates the drift by:
1. Creating a shared schema helper in query.ts that adds directory/workspace
fields to all instance route query schemas.
2. Updating all instance route query schemas to use the helper:
- session.ts: MessagesQuery, ListQuery
- file.ts: FileQuery, FindTextQuery, FindFileQuery, FindSymbolQuery
- experimental.ts: ToolListQuery, SessionListQuery
- control.ts: LogQuery (already correct, now uses helper)
- instance.ts, v2/session.ts, v2/message.ts
3. Adding reproducer tests in httpapi-query-schema-drift.test.ts that verify
the runtime accepts directory/workspace params on affected routes.
The OpenAPI spec generation in public.ts still manually injects params for
backward compatibility with the legacy SDK format, but now the runtime
schemas match, eliminating the validation errors.
Verification:
- bun typecheck passes
- 4 drift reproducer tests pass
- 24 httpapi tests pass across session, file, experimental, workspace-routing
2026-05-09 20:12:02 +00:00
|
|
|
expect(messages.status).toBe(200)
|
2026-04-30 17:53:26 +00:00
|
|
|
expect(
|
|
|
|
|
yield* Effect.sync(() =>
|
|
|
|
|
Database.use((db) =>
|
|
|
|
|
db
|
|
|
|
|
.select({ workspaceID: SessionTable.workspace_id })
|
|
|
|
|
.from(SessionTable)
|
|
|
|
|
.where(eq(SessionTable.id, created.id))
|
|
|
|
|
.get(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
).toEqual({ workspaceID: workspace.id })
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-30 15:07:00 +00:00
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"validates archived timestamp values",
|
2026-04-30 15:07:00 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
2026-05-09 13:10:42 +00:00
|
|
|
const session = yield* createSession(tmp.path, { title: "archived" })
|
2026-04-30 15:07:00 +00:00
|
|
|
const body = JSON.stringify({ time: { archived: -1 } })
|
|
|
|
|
|
2026-05-09 13:10:42 +00:00
|
|
|
const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), {
|
2026-04-30 15:07:00 +00:00
|
|
|
method: "PATCH",
|
|
|
|
|
headers,
|
|
|
|
|
body,
|
|
|
|
|
})
|
2026-05-09 13:10:42 +00:00
|
|
|
expect(response.status).toBe(200)
|
|
|
|
|
expect((yield* json<Session.Info>(response)).time.archived).toBe(-1)
|
2026-04-30 15:07:00 +00:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"uses project-scoped path and directory precedence",
|
2026-04-30 15:07:00 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
|
|
|
|
|
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
|
|
|
|
|
|
|
|
|
|
const pathSession = yield* createSession(currentDir)
|
|
|
|
|
const pathlessSession = yield* createSession(currentDir)
|
|
|
|
|
yield* Effect.sync(() =>
|
|
|
|
|
Database.use((db) =>
|
|
|
|
|
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const query = new URLSearchParams({
|
|
|
|
|
scope: "project",
|
|
|
|
|
path: "packages/opencode/src",
|
|
|
|
|
directory: currentDir,
|
|
|
|
|
})
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path }
|
2026-05-09 13:11:45 +00:00
|
|
|
const sessions = (yield* json<Session.Info[]>(
|
|
|
|
|
yield* request(`${SessionPaths.list}?${query}`, { headers }),
|
|
|
|
|
)).map((item) => item.id)
|
2026-05-09 13:10:42 +00:00
|
|
|
|
|
|
|
|
expect(sessions).toContain(pathSession.id)
|
|
|
|
|
expect(sessions).not.toContain(pathlessSession.id)
|
2026-04-30 15:07:00 +00:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"serves paginated message link headers",
|
2026-04-30 15:07:00 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
|
|
|
const session = yield* createSession(tmp.path, { title: "messages" })
|
|
|
|
|
yield* createTextMessage(tmp.path, session.id, "first")
|
|
|
|
|
yield* createTextMessage(tmp.path, session.id, "second")
|
|
|
|
|
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
|
|
|
|
|
|
2026-05-09 13:10:42 +00:00
|
|
|
const response = yield* request(route, { headers })
|
2026-04-30 15:07:00 +00:00
|
|
|
|
2026-05-09 13:10:42 +00:00
|
|
|
expect(response.headers.get("x-next-cursor")).toBeTruthy()
|
|
|
|
|
expect(response.headers.get("link")).toContain("limit=1")
|
|
|
|
|
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
|
2026-04-30 15:07:00 +00:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-27 21:38:28 +00:00
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"serves message mutation routes",
|
2026-04-27 21:38:28 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
|
|
|
|
const session = yield* createSession(tmp.path, { title: "messages" })
|
|
|
|
|
const first = yield* createTextMessage(tmp.path, session.id, "first")
|
|
|
|
|
const second = yield* createTextMessage(tmp.path, session.id, "second")
|
|
|
|
|
|
|
|
|
|
const updated = yield* requestJson<MessageV2.Part>(
|
|
|
|
|
pathFor(SessionPaths.updatePart, {
|
2026-04-26 16:24:19 +00:00
|
|
|
sessionID: session.id,
|
2026-04-27 21:38:28 +00:00
|
|
|
messageID: first.info.id,
|
|
|
|
|
partID: first.part.id,
|
2026-04-26 16:24:19 +00:00
|
|
|
}),
|
|
|
|
|
{
|
2026-04-27 21:38:28 +00:00
|
|
|
method: "PATCH",
|
2026-04-26 16:24:19 +00:00
|
|
|
headers,
|
2026-04-27 21:38:28 +00:00
|
|
|
body: JSON.stringify({ ...first.part, text: "updated" }),
|
2026-04-26 16:24:19 +00:00
|
|
|
},
|
2026-04-27 21:38:28 +00:00
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.live(
|
2026-05-09 13:10:42 +00:00
|
|
|
"serves remaining non-LLM session mutation routes",
|
2026-04-27 21:38:28 +00:00
|
|
|
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
|
|
|
|
Effect.gen(function* () {
|
|
|
|
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
|
|
|
|
const session = yield* createSession(tmp.path, { 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 })
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
yield* requestJson<boolean>(
|
|
|
|
|
pathFor(SessionPaths.permissions, {
|
|
|
|
|
sessionID: session.id,
|
|
|
|
|
permissionID: String(PermissionID.ascending()),
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers,
|
|
|
|
|
body: JSON.stringify({ response: "once" }),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).toBe(true)
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-04-26 15:49:11 +00:00
|
|
|
})
|