| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913 |
- import { afterEach, describe, expect } from "bun:test"
- import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { Deferred, Effect, Layer } from "effect"
- import type * as Scope from "effect/Scope"
- import { HttpServer } from "effect/unstable/http"
- import { ChildProcessSpawner } from "effect/unstable/process"
- import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { Flag } from "@kirincode-ai/core/flag/flag"
- import { createOpencodeClient } from "@kirincode-ai/sdk/v2"
- import { validateSession } from "../../src/cli/tui/validate-session"
- import { InstanceBootstrap } from "../../src/project/bootstrap"
- import { InstanceStore } from "../../src/project/instance-store"
- import { MessageID, PartID, SessionID } from "../../src/session/schema"
- import { MessageV2 } from "../../src/session/message-v2"
- import type { Config } from "@/config/config"
- import { Session as SessionNs } from "@/session/session"
- import { errorMessage } from "../../src/util/error"
- import { TestLLMServer } from "../lib/llm-server"
- import path from "path"
- import { resetDatabase } from "../fixture/db"
- import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
- import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
- import { testProviderConfig } from "../lib/test-provider"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { Database } from "@kirincode-ai/core/database/database"
- import { httpApiLayer } from "./httpapi-layer"
- const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
- const appLayer = AppNodeBuilder.build(
- LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]),
- [[InstanceStore.bootstrapNode, noopBootstrapLayer]],
- )
- const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer))
- const original = {
- KIRINCODE_SERVER_PASSWORD: Flag.KIRINCODE_SERVER_PASSWORD,
- KIRINCODE_SERVER_USERNAME: Flag.KIRINCODE_SERVER_USERNAME,
- }
- type ServerPath = "default" | "raw"
- type Sdk = ReturnType<typeof createOpencodeClient>
- type SdkResult = { response: Response; data?: unknown; error?: unknown }
- type Captured = { status: number; data?: unknown; error?: unknown }
- type ProjectFixture = { sdk: Sdk; directory: string }
- type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
- type TestServices =
- | FSUtil.Service
- | ChildProcessSpawner.ChildProcessSpawner
- | InstanceStore.Service
- | SessionNs.Service
- | HttpServer.HttpServer
- type TestScope = Scope.Scope | TestServices
- function client(
- serverPath: ServerPath,
- directory?: string,
- input?: {
- password?: string
- username?: string
- headers?: Record<string, string>
- workspaceID?: string
- onRequest?: (request: Request) => void
- },
- ) {
- return serverFetch(serverPath, input).pipe(
- Effect.map((fetch) =>
- createOpencodeClient({
- baseUrl: "http://localhost",
- directory,
- experimental_workspaceID: input?.workspaceID,
- headers: input?.headers,
- fetch,
- }),
- ),
- )
- }
- function serverFetch(
- serverPath: ServerPath,
- input?: { password?: string; username?: string; onRequest?: (request: Request) => void },
- ) {
- return HttpServer.HttpServer.use((server) =>
- Effect.sync(() => {
- void serverPath
- Flag.KIRINCODE_SERVER_PASSWORD = input?.password
- Flag.KIRINCODE_SERVER_USERNAME = input?.username
- const baseUrl = HttpServer.formatAddress(server.address)
- return Object.assign(
- async (request: RequestInfo | URL, init?: RequestInit) => {
- const source = request instanceof Request ? request : new Request(request, init)
- input?.onRequest?.(source)
- const url = new URL(source.url)
- return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
- },
- { preconnect: globalThis.fetch.preconnect },
- ) satisfies typeof globalThis.fetch
- }),
- )
- }
- function authorization(username: string, password: string) {
- return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
- }
- function call<T>(request: () => Promise<T>) {
- return Effect.promise(request)
- }
- function capture(request: () => Promise<SdkResult>) {
- return call(request).pipe(
- Effect.map((result) => ({
- status: result.response.status,
- data: result.data,
- error: result.error,
- })),
- )
- }
- function captureThrown(request: () => Promise<unknown>) {
- return call(async () => {
- try {
- await request()
- } catch (error) {
- return error
- }
- })
- }
- function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
- return call(request).pipe(
- Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
- Effect.asVoid,
- )
- }
- function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
- return Effect.acquireRelease(
- Effect.sync(() => new AbortController()),
- (controller) => Effect.sync(() => controller.abort()),
- ).pipe(
- Effect.flatMap((controller) =>
- Effect.acquireRelease(
- call(() => open(controller.signal)),
- (events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
- ).pipe(
- Effect.flatMap((events) =>
- call(() => events.stream.next()).pipe(
- Effect.timeoutOrElse({
- duration: "1 second",
- orElse: () => Effect.fail(new Error("timed out waiting for SDK event")),
- }),
- ),
- ),
- Effect.map((result) => result.value),
- ),
- ),
- )
- }
- function record(value: unknown) {
- return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
- }
- function array(value: unknown) {
- return Array.isArray(value) ? value : []
- }
- function statuses(input: Record<string, Captured>) {
- return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
- }
- function firstPartText(value: unknown) {
- return record(array(record(value).parts)[0]).text
- }
- function sessionTitles(value: unknown) {
- return array(value)
- .map((item) => record(item).title)
- .filter((title): title is string => typeof title === "string")
- .sort()
- }
- function resetState() {
- return Effect.promise(async () => {
- await disposeAllInstances()
- await resetDatabase()
- })
- }
- function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
- it.live(name, effect)
- }
- function httpapiInstance<A, E>(
- name: string,
- options: {
- serverPath: ServerPath
- git?: boolean
- config?: Partial<ConfigV1.Info>
- setup?: (dir: string) => Effect.Effect<void, E, TestServices>
- },
- run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
- ) {
- it.instance(
- name,
- Effect.gen(function* () {
- const instance = yield* TestInstance
- yield* options.setup?.(instance.directory) ?? Effect.void
- return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory })
- }),
- { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
- )
- }
- function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
- it.live(name, scenario("raw"))
- }
- function withProject<A, E, E2 = never>(
- serverPath: ServerPath,
- options: {
- git?: boolean
- config?: Partial<ConfigV1.Info>
- setup?: (dir: string) => Effect.Effect<void, E2, TestServices>
- },
- run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
- ) {
- return Effect.gen(function* () {
- const directory = yield* tmpdirScoped({
- git: options.git ?? false,
- config: { formatter: false, lsp: false, ...options.config },
- })
- yield* options.setup?.(directory) ?? Effect.void
- return yield* run({ sdk: yield* client(serverPath, directory), directory })
- })
- }
- function withStandardProject<A, E>(
- serverPath: ServerPath,
- run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
- ) {
- return withProject(serverPath, { setup: writeStandardFiles }, run)
- }
- function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
- return Effect.gen(function* () {
- const llm = yield* TestLLMServer
- return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
- }).pipe(Effect.provide(TestLLMServer.layer))
- }
- function withFakeLlmProject<A, E>(
- serverPath: ServerPath,
- options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
- run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
- ) {
- return Effect.gen(function* () {
- const llm = yield* TestLLMServer
- return yield* withProject(
- serverPath,
- {
- config: testProviderConfig(llm.url),
- setup: options.setup,
- },
- (input) => run({ ...input, llm }),
- )
- }).pipe(Effect.provide(TestLLMServer.layer))
- }
- function writeStandardFiles(dir: string) {
- return FSUtil.Service.use((fs) =>
- Effect.all([
- fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
- fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
- ]).pipe(Effect.asVoid),
- )
- }
- function writeProjectSkill(dir: string) {
- return FSUtil.Service.use((fs) =>
- fs.writeWithDirs(
- path.join(dir, ".kirincode", "skills", "project-rest-skill", "SKILL.md"),
- `---
- name: project-rest-skill
- description: A project skill visible to REST API prompts.
- ---
- # Project REST Skill
- `,
- ),
- )
- }
- function seedMessage(directory: string, sessionID: string) {
- const id = SessionID.make(sessionID)
- return InstanceStore.Service.use((store) =>
- store.provide(
- { directory },
- SessionNs.Service.use((svc) =>
- Effect.gen(function* () {
- const message = yield* svc.updateMessage({
- id: MessageID.ascending(),
- sessionID: id,
- role: "user",
- time: { created: Date.now() },
- agent: "test",
- model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
- tools: {},
- } satisfies SessionV1.User)
- const part = yield* svc.updatePart({
- id: PartID.ascending(),
- sessionID: id,
- messageID: message.id,
- type: "text",
- text: "seeded message",
- })
- return { message, part }
- }),
- ),
- ),
- )
- }
- afterEach(async () => {
- Flag.KIRINCODE_SERVER_PASSWORD = original.KIRINCODE_SERVER_PASSWORD
- Flag.KIRINCODE_SERVER_USERNAME = original.KIRINCODE_SERVER_USERNAME
- await disposeAllInstances()
- await resetDatabase()
- })
- describe("HttpApi SDK", () => {
- httpapi(
- "uses the generated SDK for global and control routes",
- Effect.gen(function* () {
- const sdk = yield* client("raw")
- const health = yield* call(() => sdk.global.health())
- const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
- expect(health.response.status).toBe(200)
- expect(health.data).toMatchObject({ healthy: true })
- expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
- payload: { type: "server.connected" },
- })
- expect(log.response.status).toBe(200)
- expect(log.data).toBe(true)
- yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400)
- }),
- )
- httpapiInstance(
- "uses the generated SDK for safe instance routes",
- { serverPath: "raw", git: false, setup: writeStandardFiles },
- ({ sdk }) =>
- Effect.gen(function* () {
- const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
- const session = yield* call(() => sdk.session.create({ title: "sdk" }))
- const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 }))
- expect(file.response.status).toBe(200)
- expect(file.data).toMatchObject({ content: "hello" })
- expect(session.response.status).toBe(200)
- expect(session.data).toMatchObject({ title: "sdk" })
- expect(listed.response.status).toBe(200)
- expect(listed.data?.map((item) => item.id)).toContain(session.data?.id)
- yield* Effect.all([
- expectStatus(() => sdk.project.current(), 200),
- expectStatus(() => sdk.config.get(), 200),
- expectStatus(() => sdk.config.providers(), 200),
- expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
- ])
- }),
- )
- httpapi(
- "routes configured SDK directory and workspace for v2 location GETs",
- withProject("raw", { setup: writeStandardFiles }, ({ directory }) =>
- Effect.gen(function* () {
- const workspaceID = "wrk_sdk"
- let request: Request | undefined
- const sdk = yield* client("raw", directory, {
- workspaceID,
- onRequest: (value) => (request = value),
- })
- const found = yield* pollWithTimeout(
- call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe(
- Effect.map((result) => (result.data?.data.length ? result : undefined)),
- ),
- "SDK file search index was not ready",
- )
- const url = new URL(request!.url)
- expect(found.response.status).toBe(200)
- expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
- expect(url.searchParams.get("directory")).toBe(directory)
- expect(url.searchParams.get("workspace")).toBe(workspaceID)
- expect(url.searchParams.get("location[directory]")).toBe(directory)
- expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
- expect(request!.headers.has("x-opencode-directory")).toBe(false)
- expect(request!.headers.has("x-opencode-workspace")).toBe(false)
- }),
- ),
- )
- serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
- Effect.gen(function* () {
- const sdk = yield* client(serverPath)
- const health = yield* capture(() => sdk.global.health())
- const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
- const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
- return {
- statuses: statuses({ health, log, invalidAuth }),
- health: record(health.data).healthy,
- log: log.data,
- }
- }),
- )
- serverPathParity("matches generated SDK global event stream", (serverPath) =>
- Effect.gen(function* () {
- const sdk = yield* client(serverPath)
- const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
- return { type: record(record(event).payload).type }
- }),
- )
- serverPathParity("matches generated SDK instance event stream", (serverPath) =>
- withStandardProject(serverPath, ({ sdk }) =>
- firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
- Effect.map((event) => ({ type: record(record(event).payload).type })),
- ),
- ),
- )
- serverPathParity("matches generated SDK missing session errors", (serverPath) =>
- withStandardProject(serverPath, ({ sdk }) =>
- Effect.gen(function* () {
- const sessionID = "ses_missing"
- const expected = {
- name: "NotFoundError",
- data: { message: `Session not found: ${sessionID}` },
- }
- const missing = yield* capture(() => sdk.session.get({ sessionID }))
- const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
- // Result-tuple path: error body is preserved as-is so existing
- // consumers reading `result.error.name` / `JSON.stringify(error)`
- // keep working byte-for-byte.
- expect(missing.error).toEqual(expected)
- // throwOnError path: SDK wraps the body in a real Error with the
- // server's message, with the original parsed body preserved under
- // `.cause.body`.
- expect(thrown).toBeInstanceOf(Error)
- expect((thrown as Error).message).toBe(expected.data.message)
- expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
- return {
- status: missing.status,
- error: missing.error,
- thrown,
- }
- }),
- ),
- )
- serverPathParity("formats missing session validation errors for -s", (serverPath) =>
- withStandardProject(serverPath, ({ directory }) =>
- Effect.gen(function* () {
- const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
- const fetch = yield* serverFetch(serverPath)
- const thrown = yield* captureThrown(() =>
- validateSession({
- url: "http://localhost",
- directory,
- sessionID,
- fetch,
- }),
- )
- expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
- return errorMessage(thrown)
- }),
- ),
- )
- httpapiInstance(
- "uses generated SDK basic auth behavior",
- { serverPath: "raw", setup: writeStandardFiles },
- ({ directory }) =>
- Effect.gen(function* () {
- const missingSdk = yield* client("raw", directory, { password: "secret" })
- const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
- const badSdk = yield* client("raw", directory, {
- password: "secret",
- headers: { authorization: authorization("kirincode", "wrong") },
- })
- const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
- const goodSdk = yield* client("raw", directory, {
- password: "secret",
- headers: { authorization: authorization("kirincode", "secret") },
- })
- const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
- return {
- statuses: statuses({ missing, bad, good }),
- content: record(good.data).content,
- }
- }),
- )
- serverPathParity("matches generated SDK instance read routes", (serverPath) =>
- withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
- Effect.gen(function* () {
- const project = yield* capture(() => sdk.project.current())
- const projects = yield* capture(() => sdk.project.list())
- const paths = yield* capture(() => sdk.path.get())
- const config = yield* capture(() => sdk.config.get())
- const providers = yield* capture(() => sdk.config.providers())
- const file = yield* capture(() => sdk.file.read({ path: "hello.txt" }))
- const files = yield* capture(() => sdk.file.list({ path: "." }))
- const fileStatus = yield* capture(() => sdk.file.status())
- const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 }))
- const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" }))
- const agents = yield* capture(() => sdk.app.agents())
- const skills = yield* capture(() => sdk.app.skills())
- const tools = yield* capture(() => sdk.tool.ids())
- const vcs = yield* capture(() => sdk.vcs.get())
- const formatter = yield* capture(() => sdk.formatter.status())
- const lsp = yield* capture(() => sdk.lsp.status())
- return {
- statuses: statuses({
- project,
- projects,
- paths,
- config,
- providers,
- file,
- files,
- fileStatus,
- findFiles,
- findText,
- agents,
- skills,
- tools,
- vcs,
- formatter,
- lsp,
- }),
- project: { worktreeSelected: record(project.data).worktree === directory },
- paths: { directorySelected: record(paths.data).directory === directory },
- file: record(file.data).content,
- hasProject: array(projects.data).length > 0,
- foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
- foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
- listedFile: JSON.stringify(files.data).includes("hello.txt"),
- vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
- }
- }),
- ),
- )
- serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
- withStandardProject(serverPath, ({ sdk }) =>
- Effect.gen(function* () {
- const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
- const parentID = String(record(parent.data).id)
- const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
- const childID = String(record(child.data).id)
- const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
- const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
- const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
- const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
- const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
- const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
- const status = yield* capture(() => sdk.session.status())
- const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
- const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
- const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
- const invalidCursor = yield* capture(() =>
- sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }),
- )
- const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
- const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
- return {
- statuses: statuses({
- parent,
- child,
- get,
- update,
- roots,
- all,
- children,
- todo,
- status,
- messages,
- missingGet,
- missingMessages,
- invalidCursor,
- deleted,
- getDeleted,
- }),
- getTitle: record(get.data).title,
- updatedTitle: record(update.data).title,
- rootTitles: sessionTitles(roots.data),
- allTitles: sessionTitles(all.data),
- childCount: array(children.data).length,
- todoCount: array(todo.data).length,
- messageCount: array(messages.data).length,
- }
- }),
- ),
- )
- serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
- withStandardProject(serverPath, ({ sdk, directory }) =>
- Effect.gen(function* () {
- const session = yield* capture(() => sdk.session.create({ title: "messages" }))
- const sessionID = String(record(session.data).id)
- const seeded = yield* seedMessage(directory, sessionID)
- const list = yield* capture(() => sdk.session.messages({ sessionID }))
- const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
- const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
- const partUpdate = yield* capture(() =>
- sdk.part.update({
- sessionID,
- messageID: seeded.message.id,
- partID: seeded.part.id,
- part: { ...seeded.part, text: "updated message" } as NonNullable<
- Parameters<Sdk["part"]["update"]>[0]["part"]
- >,
- }),
- )
- const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
- const partDelete = yield* capture(() =>
- sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
- )
- const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
- const deleteMessage = yield* capture(() =>
- sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }),
- )
- const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
- return {
- statuses: statuses({
- session,
- list,
- page,
- message,
- partUpdate,
- updated,
- partDelete,
- withoutPart,
- deleteMessage,
- missingMessage,
- }),
- listCount: array(list.data).length,
- pageCount: array(page.data).length,
- initialText: firstPartText(message.data),
- updatedText: firstPartText(updated.data),
- partCountAfterDelete: array(record(withoutPart.data).parts).length,
- }
- }),
- ),
- )
- // Regression: EventV2 must publish on the same ProjectBus the /event handler
- // subscribes to, AND the /event stream must forward handler ALS/context into the
- // body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
- // bus.publish → SDK subscriber path. Goes red if either the publisher uses a
- // different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 /
- // pre-#27425).
- serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) =>
- withStandardProject(serverPath, ({ sdk, directory }) =>
- Effect.gen(function* () {
- const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" }))
- const sessionID = String(record(session.data).id)
- const seeded = yield* seedMessage(directory, sessionID)
- const controller = new AbortController()
- yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
- const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal }))
- yield* Effect.addFinalizer(() =>
- call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
- )
- const ready = yield* Deferred.make<void>()
- const received = yield* Deferred.make<unknown>()
- yield* call(async () => {
- for await (const event of events.stream) {
- const payload = record(event).payload ?? event
- const type = record(payload).type
- if (type === "server.connected") {
- Deferred.doneUnsafe(ready, Effect.void)
- continue
- }
- if (type === MessageV2.Event.PartUpdated.type) {
- Deferred.doneUnsafe(received, Effect.succeed(payload))
- return
- }
- }
- }).pipe(Effect.forkScoped)
- yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds")
- const updated = yield* capture(() =>
- sdk.part.update({
- sessionID,
- messageID: seeded.message.id,
- partID: seeded.part.id,
- part: { ...seeded.part, text: "updated via sync" } as NonNullable<
- Parameters<Sdk["part"]["update"]>[0]["part"]
- >,
- }),
- )
- expect(updated.status).toBe(200)
- const event = yield* awaitWithTimeout(
- Deferred.await(received),
- "timed out waiting for message.part.updated bus payload over /event",
- "5 seconds",
- )
- const properties = record(record(event).properties)
- expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" })
- return { type: record(event).type, partType: record(properties.part).type }
- }),
- ),
- )
- serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
- withStandardProject(serverPath, ({ sdk }) =>
- Effect.gen(function* () {
- const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
- const sessionID = String(record(session.data).id)
- const prompt = yield* capture(() =>
- sdk.session.prompt({
- sessionID,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "hello" }],
- }),
- )
- const asyncPrompt = yield* capture(() =>
- sdk.session.promptAsync({
- sessionID,
- agent: "build",
- noReply: true,
- parts: [{ type: "text", text: "async hello" }],
- }),
- )
- const messages = yield* capture(() => sdk.session.messages({ sessionID }))
- return {
- statuses: statuses({ session, prompt, asyncPrompt, messages }),
- promptRole: record(record(prompt.data).info).role,
- messageCount: array(messages.data).length,
- messageTexts: array(messages.data)
- .flatMap((item) => array(record(item).parts))
- .map((part) => record(part).text)
- .filter((text): text is string => typeof text === "string")
- .sort(),
- }
- }),
- ),
- )
- serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
- withFakeLlm(serverPath, ({ sdk, llm }) =>
- Effect.gen(function* () {
- yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
- const session = yield* capture(() =>
- sdk.session.create({
- title: "llm prompt",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- }),
- )
- const sessionID = String(record(session.data).id)
- const prompt = yield* capture(() =>
- sdk.session.prompt({
- sessionID,
- agent: "build",
- model: { providerID: "test", modelID: "test-model" },
- parts: [{ type: "text", text: "hello llm" }],
- }),
- )
- const messages = yield* capture(() => sdk.session.messages({ sessionID }))
- const inputs = yield* llm.inputs
- return {
- statuses: statuses({ session, prompt, messages }),
- calls: inputs.length,
- requestedModel: inputs[0]?.model,
- responseText: JSON.stringify(prompt.data).includes("fake world"),
- persistedText: JSON.stringify(messages.data).includes("fake world"),
- userText: JSON.stringify(messages.data).includes("hello llm"),
- }
- }),
- ),
- )
- httpapi(
- "includes project skills in REST API prompt context",
- withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
- Effect.gen(function* () {
- yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } })
- const session = yield* capture(() =>
- sdk.session.create({
- title: "project skill prompt",
- permission: [{ permission: "*", pattern: "*", action: "allow" }],
- }),
- )
- const sessionID = String(record(session.data).id)
- const prompt = yield* capture(() =>
- sdk.session.prompt({
- sessionID,
- agent: "build",
- model: { providerID: "test", modelID: "test-model" },
- parts: [{ type: "text", text: "hello skill context" }],
- }),
- )
- const inputs = yield* llm.inputs
- expect(session.status).toBe(200)
- expect(prompt.status).toBe(200)
- expect(JSON.stringify(inputs[0])).toContain("project-rest-skill")
- }),
- ),
- )
- serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) =>
- withStandardProject(serverPath, ({ sdk }) =>
- Effect.gen(function* () {
- const session = yield* capture(() => sdk.session.create({ title: "tui" }))
- const sessionID = String(record(session.data).id)
- const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" }))
- const openHelp = yield* capture(() => sdk.tui.openHelp())
- const openSessions = yield* capture(() => sdk.tui.openSessions())
- const openThemes = yield* capture(() => sdk.tui.openThemes())
- const openModels = yield* capture(() => sdk.tui.openModels())
- const submitPrompt = yield* capture(() => sdk.tui.submitPrompt())
- const clearPrompt = yield* capture(() => sdk.tui.clearPrompt())
- const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" }))
- const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
- const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID }))
- const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" }))
- const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
- return {
- statuses: statuses({
- session,
- appendPrompt,
- openHelp,
- openSessions,
- openThemes,
- openModels,
- submitPrompt,
- clearPrompt,
- executeCommand,
- showToast,
- selectSession,
- missingSession,
- invalidSession,
- }),
- data: {
- appendPrompt: appendPrompt.data,
- openHelp: openHelp.data,
- openSessions: openSessions.data,
- openThemes: openThemes.data,
- openModels: openModels.data,
- submitPrompt: submitPrompt.data,
- clearPrompt: clearPrompt.data,
- executeCommand: executeCommand.data,
- showToast: showToast.data,
- selectSession: selectSession.data,
- },
- }
- }),
- ),
- )
- serverPathParity("matches generated SDK project git initialization", (serverPath) =>
- withProject(serverPath, {}, ({ sdk, directory }) =>
- Effect.gen(function* () {
- const before = yield* capture(() => sdk.project.current())
- const init = yield* capture(() => sdk.project.initGit())
- const after = yield* capture(() => sdk.project.current())
- return {
- statuses: statuses({ before, init, after }),
- before: {
- vcs: record(before.data).vcs ?? null,
- worktree: record(before.data).worktree,
- },
- init: {
- vcs: record(init.data).vcs,
- worktreeSelected: record(init.data).worktree === directory,
- },
- after: {
- vcs: record(after.data).vcs,
- worktreeSelected: record(after.data).worktree === directory,
- },
- }
- }),
- ),
- )
- })
|