import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test"), } afterEach(async () => { await disposeAllInstances() }) const withoutWatcher = (effect: Effect.Effect) => { if (process.platform !== "win32") return effect return Effect.acquireUseRelease( Effect.sync(() => { const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" return previous }), () => effect, (previous) => Effect.sync(() => { if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous }), ) } const sessionScoped = Effect.acquireRelease(SessionNs.use.create({}), (session) => SessionNs.use.remove(session.id).pipe(Effect.ignore), ) const fill = Effect.fn("SessionMessagesTest.fill")(function* ( sessionID: SessionID, count: number, time = (i: number) => Date.now() + i, ) { const session = yield* SessionNs.Service return yield* Effect.forEach( Array.from({ length: count }, (_, i) => i), (i) => Effect.gen(function* () { const id = MessageID.ascending() yield* session.updateMessage({ id, sessionID, role: "user", time: { created: time(i) }, agent: "test", model, tools: {}, } satisfies SessionV1.User) yield* session.updatePart({ id: PartID.ascending(), sessionID, messageID: id, type: "text", text: `m${i}`, } satisfies SessionV1.TextPart) return id }), ) }) function request(path: string) { return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory))) } function json(response: HttpClientResponse.HttpClientResponse) { return response.json.pipe(Effect.map((body) => body as T)) } describe("session messages endpoint", () => { it.instance( "returns cursor headers for older pages", withoutWatcher( Effect.gen(function* () { const session = yield* sessionScoped const ids = yield* fill(session.id, 5) const a = yield* request(`/session/${session.id}/message?limit=2`) expect(a.status).toBe(200) const aBody = yield* json(a) expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) const cursor = a.headers["x-next-cursor"] expect(cursor).toBeTruthy() expect(a.headers["link"]).toContain('rel="next"') const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) expect(b.status).toBe(200) const bBody = yield* json(b) expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) }), ), { git: true }, ) it.instance( "keeps full-history responses when limit is omitted", withoutWatcher( Effect.gen(function* () { const session = yield* sessionScoped const ids = yield* fill(session.id, 3) const res = yield* request(`/session/${session.id}/message`) expect(res.status).toBe(200) const body = yield* json(res) expect(body.map((item) => item.info.id)).toEqual(ids) }), ), { git: true }, ) it.instance( "rejects invalid cursors and missing sessions", withoutWatcher( Effect.gen(function* () { const session = yield* sessionScoped const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`) expect(bad.status).toBe(400) const miss = yield* request(`/session/ses_missing/message?limit=2`) expect(miss.status).toBe(404) }), ), { git: true }, ) it.instance( "does not truncate large legacy limit requests", withoutWatcher( Effect.gen(function* () { const session = yield* sessionScoped yield* fill(session.id, 520) const res = yield* request(`/session/${session.id}/message?limit=510`) expect(res.status).toBe(200) const body = yield* json(res) expect(body).toHaveLength(510) }), ), { git: true }, ) it.instance( "accepts directory query used by workspace routing", withoutWatcher( Effect.gen(function* () { const tmp = yield* TestInstance const session = yield* sessionScoped yield* fill(session.id, 1) const res = yield* request( `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`, ) expect(res.status).toBe(200) const body = yield* json(res) expect(Array.isArray(body)).toBe(true) expect(body).toHaveLength(1) }), ), { git: true }, ) })