httpapi-session.test.ts 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. import { PermissionV1 } from "@opencode-ai/core/v1/permission"
  2. import { afterEach, describe, expect } from "bun:test"
  3. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  4. import { SessionV1 } from "@opencode-ai/core/v1/session"
  5. import { mkdir } from "node:fs/promises"
  6. import path from "node:path"
  7. import { Cause, Config, Effect, Exit, Layer } from "effect"
  8. import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http"
  9. import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
  10. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  11. import { Flag } from "@opencode-ai/core/flag/flag"
  12. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  13. import { Ripgrep } from "@opencode-ai/core/ripgrep"
  14. import { registerAdapter } from "../../src/control-plane/adapters"
  15. import type { WorkspaceAdapter } from "../../src/control-plane/types"
  16. import { Workspace } from "../../src/control-plane/workspace"
  17. import { InstanceBootstrap } from "../../src/project/bootstrap"
  18. import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
  19. import { InstanceStore } from "../../src/project/instance-store"
  20. import { Project } from "../../src/project/project"
  21. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  22. import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors"
  23. import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
  24. import { Session } from "@/session/session"
  25. import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
  26. import { Database } from "@opencode-ai/core/database/database"
  27. import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
  28. import { SessionMessage } from "@opencode-ai/core/session/message"
  29. import { ModelV2 } from "@opencode-ai/core/model"
  30. import { ProviderV2 } from "@opencode-ai/core/provider"
  31. import * as DateTime from "effect/DateTime"
  32. import { eq } from "drizzle-orm"
  33. import { resetDatabase } from "../fixture/db"
  34. import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
  35. import { TestLLMServer } from "../lib/llm-server"
  36. import { testProviderConfig } from "../lib/test-provider"
  37. import { pollWithTimeout, testEffect } from "../lib/effect"
  38. const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  39. const workspaceLayer = Workspace.defaultLayer.pipe(
  40. Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])),
  41. )
  42. const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [
  43. [
  44. InstanceStore.bootstrapNode,
  45. Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
  46. ],
  47. ])
  48. const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
  49. HttpApiApp.routes,
  50. {
  51. disableListenLog: true,
  52. disableLogger: true,
  53. },
  54. )
  55. const httpApiLayer = servedRoutes.pipe(
  56. Layer.provide(layerWebSocketConstructorGlobal),
  57. Layer.provideMerge(NodeHttpServer.layerTest),
  58. Layer.provideMerge(NodeServices.layer),
  59. )
  60. const it = testEffect(
  61. Layer.mergeAll(
  62. instanceStoreLayer,
  63. Project.defaultLayer,
  64. Session.defaultLayer,
  65. workspaceLayer,
  66. Database.defaultLayer,
  67. httpApiLayer,
  68. ).pipe(Layer.provide(Ripgrep.defaultLayer)),
  69. )
  70. function pathFor(path: string, params: Record<string, string>) {
  71. return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
  72. }
  73. function createSession(input?: Session.CreateInput) {
  74. return Session.use.create(input)
  75. }
  76. function createTextMessage(sessionID: SessionIDType, text: string) {
  77. return Effect.gen(function* () {
  78. const svc = yield* Session.Service
  79. const info = yield* svc.updateMessage({
  80. id: MessageID.ascending(),
  81. role: "user",
  82. sessionID,
  83. agent: "build",
  84. model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
  85. time: { created: Date.now() },
  86. })
  87. const part = yield* svc.updatePart({
  88. id: PartID.ascending(),
  89. sessionID,
  90. messageID: info.id,
  91. type: "text",
  92. text,
  93. })
  94. return { info, part }
  95. })
  96. }
  97. const localAdapter = (directory: string): WorkspaceAdapter => ({
  98. name: "Local Test",
  99. description: "Create a local test workspace",
  100. configure: (info) => ({ ...info, name: "local-test", directory }),
  101. create: async () => {
  102. await mkdir(directory, { recursive: true })
  103. },
  104. async remove() {},
  105. target: () => ({ type: "local" as const, directory }),
  106. })
  107. const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
  108. Effect.acquireRelease(
  109. Effect.gen(function* () {
  110. registerAdapter(input.projectID, input.type, localAdapter(input.directory))
  111. return yield* Workspace.Service.use((svc) =>
  112. svc.create({
  113. type: input.type,
  114. branch: null,
  115. extra: null,
  116. projectID: input.projectID,
  117. }),
  118. )
  119. }),
  120. (info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
  121. )
  122. const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = seq) =>
  123. Effect.gen(function* () {
  124. const message = SessionMessage.Assistant.make({
  125. id: SessionMessage.ID.create(),
  126. type: "assistant",
  127. agent: "build",
  128. model: {
  129. id: ModelV2.ID.make("model"),
  130. providerID: ProviderV2.ID.make("provider"),
  131. variant: ModelV2.VariantID.make("default"),
  132. },
  133. time: { created: DateTime.makeUnsafe(time) },
  134. content: [],
  135. })
  136. const { db } = yield* Database.Service
  137. yield* db
  138. .insert(SessionMessageTable)
  139. .values([
  140. {
  141. id: message.id,
  142. session_id: sessionID,
  143. type: message.type,
  144. seq,
  145. time_created: time,
  146. data: {
  147. time: { created: time },
  148. agent: message.agent,
  149. model: message.model,
  150. content: message.content,
  151. } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
  152. },
  153. ])
  154. .run()
  155. .pipe(Effect.orDie)
  156. return message
  157. })
  158. const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
  159. Effect.gen(function* () {
  160. const { db } = yield* Database.Service
  161. yield* db
  162. .insert(SessionMessageTable)
  163. .values([
  164. {
  165. id: SessionMessage.ID.create(),
  166. session_id: sessionID,
  167. type: "assistant",
  168. seq: time,
  169. time_created: time,
  170. data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
  171. },
  172. ])
  173. .run()
  174. .pipe(Effect.orDie)
  175. })
  176. const setLegacySummaryDiff = (sessionID: SessionIDType) =>
  177. Effect.gen(function* () {
  178. const { db } = yield* Database.Service
  179. yield* db
  180. .update(SessionTable)
  181. .set({
  182. summary_additions: 1,
  183. summary_deletions: 0,
  184. summary_files: 1,
  185. summary_diffs: [{ additions: 1, deletions: 0 }],
  186. })
  187. .where(eq(SessionTable.id, sessionID))
  188. .run()
  189. .pipe(Effect.orDie)
  190. })
  191. const getWorkspaceID = (sessionID: SessionIDType) =>
  192. Effect.gen(function* () {
  193. const { db } = yield* Database.Service
  194. return yield* db
  195. .select({ workspaceID: SessionTable.workspace_id })
  196. .from(SessionTable)
  197. .where(eq(SessionTable.id, sessionID))
  198. .get()
  199. .pipe(Effect.orDie)
  200. })
  201. const clearSessionPath = (sessionID: SessionIDType) =>
  202. Effect.gen(function* () {
  203. const { db } = yield* Database.Service
  204. yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
  205. })
  206. function request(path: string, init?: RequestInit) {
  207. const url = new URL(path, "http://localhost")
  208. return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
  209. HttpClientRequest.setUrl(url.pathname),
  210. HttpClient.execute,
  211. )
  212. }
  213. function json<T>(response: HttpClientResponse.HttpClientResponse) {
  214. if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text))))
  215. return response.json.pipe(Effect.map((value) => value as T))
  216. }
  217. function responseJson(response: HttpClientResponse.HttpClientResponse) {
  218. return response.json
  219. }
  220. function requestJson<T>(path: string, init?: RequestInit) {
  221. return request(path, init).pipe(Effect.flatMap(json<T>))
  222. }
  223. afterEach(async () => {
  224. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  225. await disposeAllInstances()
  226. await resetDatabase()
  227. })
  228. describe("session HttpApi", () => {
  229. it.effect("maps busy sessions to public session busy errors", () =>
  230. Effect.gen(function* () {
  231. const sessionID = SessionID.descending()
  232. const exit = yield* HttpSessionError.mapBusy(Effect.fail(new Session.BusyError({ sessionID }))).pipe(Effect.exit)
  233. expect(Exit.isFailure(exit)).toBe(true)
  234. if (Exit.isFailure(exit)) {
  235. expect(Cause.squash(exit.cause)).toMatchObject({
  236. _tag: "SessionBusyError",
  237. sessionID,
  238. message: `Session is busy: ${sessionID}`,
  239. })
  240. }
  241. }),
  242. )
  243. it.instance(
  244. "returns declared not found errors for read routes",
  245. () =>
  246. Effect.gen(function* () {
  247. const test = yield* TestInstance
  248. const headers = { "x-opencode-directory": test.directory }
  249. const missingSession = SessionID.descending()
  250. const missingSessionBody = {
  251. name: "NotFoundError",
  252. data: { message: `Session not found: ${missingSession}` },
  253. }
  254. const get = yield* request(pathFor(SessionPaths.get, { sessionID: missingSession }), { headers })
  255. expect(get.status).toBe(404)
  256. expect(yield* responseJson(get)).toEqual(missingSessionBody)
  257. const children = yield* request(pathFor(SessionPaths.children, { sessionID: missingSession }), { headers })
  258. expect(children.status).toBe(404)
  259. expect(yield* responseJson(children)).toEqual(missingSessionBody)
  260. const todo = yield* request(pathFor(SessionPaths.todo, { sessionID: missingSession }), { headers })
  261. expect(todo.status).toBe(404)
  262. expect(yield* responseJson(todo)).toEqual(missingSessionBody)
  263. const messages = yield* request(pathFor(SessionPaths.messages, { sessionID: missingSession }), { headers })
  264. expect(messages.status).toBe(404)
  265. expect(yield* responseJson(messages)).toEqual(missingSessionBody)
  266. const remove = yield* request(pathFor(SessionPaths.remove, { sessionID: missingSession }), {
  267. headers,
  268. method: "DELETE",
  269. })
  270. expect(remove.status).toBe(404)
  271. expect(yield* responseJson(remove)).toEqual(missingSessionBody)
  272. const prompt = yield* request(pathFor(SessionPaths.prompt, { sessionID: missingSession }), {
  273. headers: { ...headers, "content-type": "application/json" },
  274. method: "POST",
  275. body: JSON.stringify({ agent: "build", noReply: true, parts: [{ type: "text", text: "hello" }] }),
  276. })
  277. expect(prompt.status).toBe(404)
  278. expect(yield* responseJson(prompt)).toEqual(missingSessionBody)
  279. const abort = yield* request(pathFor(SessionPaths.abort, { sessionID: missingSession }), {
  280. headers,
  281. method: "POST",
  282. })
  283. expect(abort.status).toBe(200)
  284. expect(yield* responseJson(abort)).toBe(true)
  285. const session = yield* createSession({ title: "missing message" })
  286. const missingMessage = MessageID.ascending()
  287. const message = yield* request(
  288. pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
  289. { headers },
  290. )
  291. expect(message.status).toBe(404)
  292. expect(yield* responseJson(message)).toEqual({
  293. name: "NotFoundError",
  294. data: { message: `Message not found: ${missingMessage}` },
  295. })
  296. }),
  297. { git: true, config: { formatter: false, lsp: false } },
  298. )
  299. it.instance(
  300. "serves read routes",
  301. () =>
  302. Effect.gen(function* () {
  303. const test = yield* TestInstance
  304. const headers = { "x-opencode-directory": test.directory }
  305. const parent = yield* createSession({ title: "parent" })
  306. const child = yield* createSession({ title: "child", parentID: parent.id })
  307. const message = yield* createTextMessage(parent.id, "hello")
  308. yield* createTextMessage(parent.id, "world")
  309. const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?roots=true`, { headers })
  310. expect(listed.map((item) => item.id)).toContain(parent.id)
  311. expect(Object.hasOwn(listed[0]!, "parentID")).toBe(false)
  312. expect(yield* requestJson<Record<string, unknown>>(SessionPaths.status, { headers })).toEqual({})
  313. expect(
  314. yield* requestJson<Session.Info>(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }),
  315. ).toMatchObject({ id: parent.id, title: "parent" })
  316. expect(
  317. (yield* requestJson<Session.Info[]>(pathFor(SessionPaths.children, { sessionID: parent.id }), {
  318. headers,
  319. })).map((item) => item.id),
  320. ).toEqual([child.id])
  321. expect(
  322. yield* requestJson<unknown[]>(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }),
  323. ).toEqual([])
  324. expect(
  325. yield* requestJson<unknown[]>(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }),
  326. ).toEqual([])
  327. const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
  328. headers,
  329. })
  330. const messagePage = yield* json<SessionV1.WithParts[]>(messages)
  331. const nextCursor = messages.headers["x-next-cursor"]
  332. expect(nextCursor).toBeTruthy()
  333. expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
  334. expect(
  335. (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
  336. headers,
  337. })).status,
  338. ).toBe(400)
  339. expect(
  340. (yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
  341. headers,
  342. })).status,
  343. ).toBe(400)
  344. expect(
  345. yield* requestJson<SessionV1.WithParts>(
  346. pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
  347. { headers },
  348. ),
  349. ).toMatchObject({ info: { id: message.info.id } })
  350. yield* insertLegacyAssistantMessage(parent.id)
  351. expect(
  352. (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, {
  353. headers,
  354. })).data,
  355. ).toMatchObject([{ type: "assistant" }])
  356. }),
  357. { git: true, config: { formatter: false, lsp: false } },
  358. )
  359. it.live("uses the persisted session directory for prompt requests", () =>
  360. Effect.gen(function* () {
  361. const llm = yield* TestLLMServer
  362. yield* llm.text("ok", { usage: { input: 1, output: 1 } })
  363. const config = testProviderConfig(llm.url)
  364. const sessionDirectory = yield* tmpdirScoped({ git: true, config })
  365. const requestDirectory = yield* tmpdirScoped({ git: true, config })
  366. const session = yield* createSession({ title: "directory regression" }).pipe(
  367. provideInstanceEffect(sessionDirectory),
  368. )
  369. const response = yield* request(
  370. `${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
  371. {
  372. method: "POST",
  373. headers: { "content-type": "application/json" },
  374. body: JSON.stringify({
  375. agent: "build",
  376. model: { providerID: "test", modelID: "test-model" },
  377. parts: [{ type: "text", text: "which directory?" }],
  378. }),
  379. },
  380. )
  381. expect(response.status).toBe(200)
  382. yield* responseJson(response)
  383. const messages = yield* Session.use
  384. .messages({ sessionID: session.id })
  385. .pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
  386. const assistant = messages.find((message) => message.info.role === "assistant")
  387. expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
  388. cwd: sessionDirectory,
  389. root: sessionDirectory,
  390. })
  391. }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
  392. )
  393. it.instance(
  394. "returns v2 public request errors for cursor and workspace query failures",
  395. () =>
  396. Effect.gen(function* () {
  397. const test = yield* TestInstance
  398. const headers = { "x-opencode-directory": test.directory }
  399. const session = yield* createSession({ title: "v2 cursor" })
  400. const firstMessage = yield* insertLegacyAssistantMessage(session.id, 1, 2)
  401. const secondMessage = yield* insertLegacyAssistantMessage(session.id, 2, 1)
  402. const sessionPage = yield* request(
  403. `/api/session?${new URLSearchParams({
  404. limit: "1",
  405. order: "asc",
  406. directory: test.directory,
  407. search: "v2",
  408. })}`,
  409. { headers },
  410. )
  411. const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor
  412. .next
  413. expect(sessionCursor).toBeTruthy()
  414. expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
  415. order: "asc",
  416. directory: test.directory,
  417. search: "v2",
  418. anchor: { id: session.id, direction: "next" },
  419. })
  420. const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers })
  421. expect(sessionNextPage.status).toBe(200)
  422. const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
  423. expect(invalidSessionCursor.status).toBe(400)
  424. expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
  425. _tag: "InvalidCursorError",
  426. message: "Invalid cursor",
  427. })
  428. const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
  429. expect(invalidWorkspace.status).toBe(400)
  430. expect(yield* responseJson(invalidWorkspace)).toMatchObject({
  431. _tag: "InvalidRequestError",
  432. kind: "Query",
  433. })
  434. const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
  435. const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage)
  436. const messageCursor = messageBody.cursor.next
  437. expect(messageCursor).toBeTruthy()
  438. expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id])
  439. expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({
  440. id: secondMessage.id,
  441. order: "desc",
  442. direction: "next",
  443. })
  444. const nextMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${messageCursor}`, {
  445. headers,
  446. })
  447. expect(
  448. (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id),
  449. ).toEqual([firstMessage.id])
  450. const legacyMessageCursor = Buffer.from(
  451. JSON.stringify({ id: secondMessage.id, time: 1, order: "desc", direction: "next" }),
  452. ).toString("base64url")
  453. const legacyMessagePage = yield* request(`/api/session/${session.id}/message?cursor=${legacyMessageCursor}`, {
  454. headers,
  455. })
  456. expect(
  457. (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id),
  458. ).toEqual([firstMessage.id])
  459. const messageCursorWithOrder = yield* request(
  460. `/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
  461. { headers },
  462. )
  463. expect(messageCursorWithOrder.status).toBe(400)
  464. expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({
  465. _tag: "InvalidCursorError",
  466. message: "Cursor cannot be combined with order",
  467. })
  468. const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers })
  469. expect(invalidMessageCursor.status).toBe(400)
  470. expect(yield* responseJson(invalidMessageCursor)).toMatchObject({
  471. _tag: "InvalidCursorError",
  472. message: "Invalid cursor",
  473. })
  474. }),
  475. { git: true, config: { formatter: false, lsp: false } },
  476. )
  477. it.instance(
  478. "returns v2 public not found errors for missing sessions",
  479. () =>
  480. Effect.gen(function* () {
  481. const test = yield* TestInstance
  482. const headers = { "x-opencode-directory": test.directory }
  483. const missing = SessionID.descending()
  484. const expected = {
  485. _tag: "SessionNotFoundError",
  486. sessionID: missing,
  487. message: `Session not found: ${missing}`,
  488. }
  489. const messages = yield* request(`/api/session/${missing}/message`, { headers })
  490. expect(messages.status).toBe(404)
  491. expect(yield* responseJson(messages)).toEqual(expected)
  492. const context = yield* request(`/api/session/${missing}/context`, { headers })
  493. expect(context.status).toBe(404)
  494. expect(yield* responseJson(context)).toEqual(expected)
  495. const compact = yield* request(`/api/session/${missing}/compact`, { method: "POST", headers })
  496. expect(compact.status).toBe(404)
  497. expect(yield* responseJson(compact)).toEqual(expected)
  498. const wait = yield* request(`/api/session/${missing}/wait`, { method: "POST", headers })
  499. expect(wait.status).toBe(404)
  500. expect(yield* responseJson(wait)).toEqual(expected)
  501. const prompt = yield* request(`/api/session/${missing}/prompt`, {
  502. method: "POST",
  503. headers: { ...headers, "content-type": "application/json" },
  504. body: JSON.stringify({ prompt: { text: "hello" } }),
  505. })
  506. expect(prompt.status).toBe(404)
  507. expect(yield* responseJson(prompt)).toEqual(expected)
  508. }),
  509. { git: true, config: { formatter: false, lsp: false } },
  510. )
  511. it.instance(
  512. "durably records one v2 prompt for exact message-ID retries",
  513. () =>
  514. Effect.gen(function* () {
  515. const test = yield* TestInstance
  516. const headers = { "x-opencode-directory": test.directory }
  517. const session = yield* createSession({ title: "v2 prompt recording" })
  518. const recordPrompt = () =>
  519. request(`/api/session/${session.id}/prompt`, {
  520. method: "POST",
  521. headers: { ...headers, "content-type": "application/json" },
  522. body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }),
  523. })
  524. const first = yield* recordPrompt()
  525. const retried = yield* recordPrompt()
  526. type PromptBody = { id: string; prompt: { text: string }; delivery: string; promotedSeq?: number }
  527. const firstBody = yield* json<{ data: PromptBody }>(first)
  528. const retriedBody = yield* json<{ data: PromptBody }>(retried)
  529. expect(first.status).toBe(200)
  530. expect(retried.status).toBe(200)
  531. expect(retriedBody).toEqual(firstBody)
  532. expect(firstBody).toMatchObject({
  533. data: { id: "msg_http_prompt", prompt: { text: "hello" }, delivery: "steer" },
  534. })
  535. const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, {
  536. headers,
  537. })
  538. expect(messages.data).toHaveLength(0)
  539. const admitted = yield* Database.Service.use(({ db }) =>
  540. db
  541. .select()
  542. .from(SessionInputTable)
  543. .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_http_prompt")))
  544. .get()
  545. .pipe(Effect.orDie),
  546. )
  547. expect(admitted).toMatchObject({
  548. id: "msg_http_prompt",
  549. session_id: session.id,
  550. delivery: "steer",
  551. promoted_seq: null,
  552. })
  553. const conflict = yield* request(`/api/session/${session.id}/prompt`, {
  554. method: "POST",
  555. headers: { ...headers, "content-type": "application/json" },
  556. body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
  557. })
  558. expect(conflict.status).toBe(409)
  559. expect(yield* responseJson(conflict)).toEqual({
  560. _tag: "ConflictError",
  561. message: "Prompt message ID conflicts with an existing durable record: msg_http_prompt",
  562. resource: "msg_http_prompt",
  563. })
  564. const wakeID = SessionMessage.ID.make("msg_http_wake")
  565. const wake = yield* request(`/api/session/${session.id}/prompt`, {
  566. method: "POST",
  567. headers: { ...headers, "content-type": "application/json" },
  568. body: JSON.stringify({ id: wakeID, prompt: { text: "hello again" } }),
  569. })
  570. expect(wake.status).toBe(200)
  571. const message = yield* pollWithTimeout(
  572. requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe(
  573. Effect.map(({ data }) => data.find((message) => message.id === wakeID)),
  574. ),
  575. "V2 prompt was not promoted after wake",
  576. "10 seconds",
  577. )
  578. expect(message).toMatchObject({ id: wakeID, type: "user" })
  579. }),
  580. { git: true, config: { formatter: false, lsp: false } },
  581. )
  582. it.instance(
  583. "returns v2 public unavailable errors for unfinished session mutations",
  584. () =>
  585. Effect.gen(function* () {
  586. const test = yield* TestInstance
  587. const headers = { "x-opencode-directory": test.directory }
  588. const session = yield* createSession({ title: "v2 unavailable" })
  589. const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers })
  590. expect(compact.status).toBe(503)
  591. expect(yield* responseJson(compact)).toEqual({
  592. _tag: "ServiceUnavailableError",
  593. message: "Session compact is not available yet",
  594. service: "session.compact",
  595. })
  596. const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers })
  597. expect(wait.status).toBe(503)
  598. expect(yield* responseJson(wait)).toEqual({
  599. _tag: "ServiceUnavailableError",
  600. message: "Session wait is not available yet",
  601. service: "session.wait",
  602. })
  603. }),
  604. { git: true, config: { formatter: false, lsp: false } },
  605. )
  606. it.instance(
  607. "returns safe v2 unknown errors for corrupt projected messages",
  608. () =>
  609. Effect.gen(function* () {
  610. const test = yield* TestInstance
  611. const session = yield* createSession({ title: "v2 corrupt message" })
  612. yield* insertCorruptV2Message(session.id)
  613. const messages = yield* request(`/api/session/${session.id}/message`, {
  614. headers: { "x-opencode-directory": test.directory },
  615. })
  616. const messagesBody = yield* responseJson(messages)
  617. expect(messages.status).toBe(500)
  618. expect(messagesBody).toMatchObject({
  619. _tag: "UnknownError",
  620. message: "Unexpected server error. Check server logs for details.",
  621. })
  622. expect((messagesBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
  623. expect(JSON.stringify(messagesBody)).not.toContain("assistant")
  624. const context = yield* request(`/api/session/${session.id}/context`, {
  625. headers: { "x-opencode-directory": test.directory },
  626. })
  627. const contextBody = yield* responseJson(context)
  628. expect(context.status).toBe(500)
  629. expect(contextBody).toMatchObject({
  630. _tag: "UnknownError",
  631. message: "Unexpected server error. Check server logs for details.",
  632. })
  633. expect((contextBody as { ref?: unknown }).ref).toMatch(/^err_[0-9a-f-]{8}$/)
  634. expect(JSON.stringify(contextBody)).not.toContain("assistant")
  635. }),
  636. { git: true, config: { formatter: false, lsp: false } },
  637. )
  638. it.instance(
  639. "serves sessions with migrated summary diffs missing file details",
  640. () =>
  641. Effect.gen(function* () {
  642. const test = yield* TestInstance
  643. const session = yield* createSession({ title: "legacy diff" })
  644. yield* setLegacySummaryDiff(session.id)
  645. const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), {
  646. headers: { "x-opencode-directory": test.directory },
  647. })
  648. expect(response.status).toBe(200)
  649. expect((yield* json<Session.Info>(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }])
  650. }),
  651. { git: true, config: { formatter: false, lsp: false } },
  652. )
  653. it.instance(
  654. "serves lifecycle mutation routes",
  655. () =>
  656. Effect.gen(function* () {
  657. const test = yield* TestInstance
  658. const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
  659. const createdEmpty = yield* requestJson<Session.Info>(SessionPaths.create, {
  660. method: "POST",
  661. headers,
  662. })
  663. expect(createdEmpty.id).toBeTruthy()
  664. const created = yield* requestJson<Session.Info>(SessionPaths.create, {
  665. method: "POST",
  666. headers,
  667. body: JSON.stringify({ title: "created" }),
  668. })
  669. expect(created.title).toBe("created")
  670. const updated = yield* requestJson<Session.Info>(pathFor(SessionPaths.update, { sessionID: created.id }), {
  671. method: "PATCH",
  672. headers,
  673. body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
  674. })
  675. expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
  676. const forked = yield* requestJson<Session.Info>(pathFor(SessionPaths.fork, { sessionID: created.id }), {
  677. method: "POST",
  678. headers,
  679. })
  680. expect(forked.id).not.toBe(created.id)
  681. const forkedWithoutContentType = yield* requestJson<Session.Info>(
  682. pathFor(SessionPaths.fork, { sessionID: created.id }),
  683. {
  684. method: "POST",
  685. headers: { "x-opencode-directory": test.directory },
  686. },
  687. )
  688. expect(forkedWithoutContentType.id).not.toBe(created.id)
  689. const invalidFork = yield* request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
  690. method: "POST",
  691. headers,
  692. body: "{",
  693. })
  694. expect(invalidFork.status).toBe(400)
  695. const forkedWhitespace = yield* requestJson<Session.Info>(
  696. pathFor(SessionPaths.fork, { sessionID: created.id }),
  697. {
  698. method: "POST",
  699. headers,
  700. body: " \n",
  701. },
  702. )
  703. expect(forkedWhitespace.id).not.toBe(created.id)
  704. expect(
  705. yield* requestJson<boolean>(pathFor(SessionPaths.abort, { sessionID: created.id }), {
  706. method: "POST",
  707. headers,
  708. }),
  709. ).toBe(true)
  710. expect(
  711. yield* requestJson<boolean>(pathFor(SessionPaths.remove, { sessionID: created.id }), {
  712. method: "DELETE",
  713. headers,
  714. }),
  715. ).toBe(true)
  716. }),
  717. { git: true, config: { formatter: false, lsp: false, share: "disabled" } },
  718. )
  719. it.instance(
  720. "persists selected workspace id when creating a session",
  721. () =>
  722. Effect.gen(function* () {
  723. const test = yield* TestInstance
  724. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
  725. const project = yield* Project.use.fromDirectory(test.directory)
  726. const workspace = yield* createLocalWorkspace({
  727. projectID: project.project.id,
  728. type: "session-create-workspace",
  729. directory: path.join(test.directory, ".workspace-local"),
  730. })
  731. const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
  732. method: "POST",
  733. headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
  734. body: JSON.stringify({ title: "workspace session" }),
  735. })
  736. const messages = yield* request(
  737. `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`,
  738. {
  739. headers: { "x-opencode-directory": test.directory },
  740. },
  741. )
  742. expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
  743. expect(messages.status).toBe(200)
  744. expect(yield* getWorkspaceID(created.id)).toEqual({ workspaceID: workspace.id })
  745. }),
  746. { git: true, config: { formatter: false, lsp: false, share: "disabled" } },
  747. )
  748. it.instance(
  749. "validates archived timestamp values",
  750. () =>
  751. Effect.gen(function* () {
  752. const test = yield* TestInstance
  753. const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
  754. const session = yield* createSession({ title: "archived" })
  755. const body = JSON.stringify({ time: { archived: -1 } })
  756. const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), {
  757. method: "PATCH",
  758. headers,
  759. body,
  760. })
  761. expect(response.status).toBe(200)
  762. expect((yield* json<Session.Info>(response)).time.archived).toBe(-1)
  763. }),
  764. { git: true, config: { formatter: false, lsp: false } },
  765. )
  766. it.instance(
  767. "uses project-scoped path and directory precedence",
  768. () =>
  769. Effect.gen(function* () {
  770. const test = yield* TestInstance
  771. const currentDir = path.join(test.directory, "packages", "opencode", "src")
  772. yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
  773. const store = yield* InstanceStore.Service
  774. const { pathSession, pathlessSession } = yield* store.provide(
  775. { directory: currentDir },
  776. Effect.gen(function* () {
  777. return {
  778. pathSession: yield* createSession(),
  779. pathlessSession: yield* createSession(),
  780. }
  781. }).pipe(Effect.provideService(TestInstance, { directory: currentDir }), Effect.provide(Session.defaultLayer)),
  782. )
  783. yield* clearSessionPath(pathlessSession.id)
  784. const query = new URLSearchParams({
  785. scope: "project",
  786. path: "packages/opencode/src",
  787. directory: currentDir,
  788. })
  789. const headers = { "x-opencode-directory": test.directory }
  790. const sessions = (yield* json<Session.Info[]>(
  791. yield* request(`${SessionPaths.list}?${query}`, { headers }),
  792. )).map((item) => item.id)
  793. expect(sessions).toContain(pathSession.id)
  794. expect(sessions).not.toContain(pathlessSession.id)
  795. }),
  796. { git: true, config: { formatter: false, lsp: false } },
  797. )
  798. it.instance(
  799. "serves paginated message link headers",
  800. () =>
  801. Effect.gen(function* () {
  802. const test = yield* TestInstance
  803. const headers = { "x-opencode-directory": test.directory }
  804. const session = yield* createSession({ title: "messages" })
  805. yield* createTextMessage(session.id, "first")
  806. yield* createTextMessage(session.id, "second")
  807. const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
  808. const response = yield* request(route, { headers })
  809. expect(response.headers["x-next-cursor"]).toBeTruthy()
  810. expect(response.headers["link"]).toContain("limit=1")
  811. expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor")
  812. }),
  813. { git: true, config: { formatter: false, lsp: false } },
  814. )
  815. it.instance(
  816. "serves message mutation routes",
  817. () =>
  818. Effect.gen(function* () {
  819. const test = yield* TestInstance
  820. const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
  821. const session = yield* createSession({ title: "messages" })
  822. const first = yield* createTextMessage(session.id, "first")
  823. const second = yield* createTextMessage(session.id, "second")
  824. const updated = yield* requestJson<SessionV1.Part>(
  825. pathFor(SessionPaths.updatePart, {
  826. sessionID: session.id,
  827. messageID: first.info.id,
  828. partID: first.part.id,
  829. }),
  830. {
  831. method: "PATCH",
  832. headers,
  833. body: JSON.stringify({ ...first.part, text: "updated" }),
  834. },
  835. )
  836. expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
  837. expect(
  838. yield* requestJson<boolean>(
  839. pathFor(SessionPaths.deletePart, {
  840. sessionID: session.id,
  841. messageID: first.info.id,
  842. partID: first.part.id,
  843. }),
  844. { method: "DELETE", headers },
  845. ),
  846. ).toBe(true)
  847. expect(
  848. yield* requestJson<boolean>(
  849. pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }),
  850. { method: "DELETE", headers },
  851. ),
  852. ).toBe(true)
  853. }),
  854. { git: true, config: { formatter: false, lsp: false } },
  855. )
  856. it.instance(
  857. "rejects part updates whose path and body ids disagree",
  858. () =>
  859. Effect.gen(function* () {
  860. const test = yield* TestInstance
  861. const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
  862. const session = yield* createSession({ title: "part mismatch" })
  863. const message = yield* createTextMessage(session.id, "first")
  864. const response = yield* request(
  865. pathFor(SessionPaths.updatePart, {
  866. sessionID: session.id,
  867. messageID: message.info.id,
  868. partID: message.part.id,
  869. }),
  870. {
  871. method: "PATCH",
  872. headers,
  873. body: JSON.stringify({ ...message.part, id: PartID.ascending() }),
  874. },
  875. )
  876. expect(response.status).toBe(400)
  877. }),
  878. { git: true, config: { formatter: false, lsp: false } },
  879. )
  880. it.instance(
  881. "serves remaining non-LLM session mutation routes",
  882. () =>
  883. Effect.gen(function* () {
  884. const test = yield* TestInstance
  885. const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
  886. const session = yield* createSession({ title: "remaining" })
  887. expect(
  888. yield* requestJson<Session.Info>(pathFor(SessionPaths.revert, { sessionID: session.id }), {
  889. method: "POST",
  890. headers,
  891. body: JSON.stringify({ messageID: MessageID.ascending() }),
  892. }),
  893. ).toMatchObject({ id: session.id })
  894. expect(
  895. yield* requestJson<Session.Info>(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
  896. method: "POST",
  897. headers,
  898. }),
  899. ).toMatchObject({ id: session.id })
  900. const permissionID = String(PermissionV1.ID.ascending())
  901. const permission = yield* request(
  902. pathFor(SessionPaths.permissions, {
  903. sessionID: session.id,
  904. permissionID,
  905. }),
  906. {
  907. method: "POST",
  908. headers,
  909. body: JSON.stringify({ response: "once" }),
  910. },
  911. )
  912. expect(permission.status).toBe(404)
  913. expect(yield* responseJson(permission)).toEqual({
  914. _tag: "PermissionNotFoundError",
  915. requestID: permissionID,
  916. message: `Permission request not found: ${permissionID}`,
  917. })
  918. }),
  919. { git: true, config: { formatter: false, lsp: false } },
  920. )
  921. })