httpapi-session.test.ts 42 KB

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