import { afterEach, describe, expect } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-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 "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-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 "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-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 = { OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } type ServerPath = "default" | "raw" type Sdk = ReturnType 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 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.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_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(request: () => Promise) { return Effect.promise(request) } function capture(request: () => Promise) { return call(request).pipe( Effect.map((result) => ({ status: result.response.status, data: result.data, error: result.error, })), ) } function captureThrown(request: () => Promise) { 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 }>) { 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) { 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(name: string, effect: Effect.Effect) { it.live(name, effect) } function httpapiInstance( name: string, options: { serverPath: ServerPath git?: boolean config?: Partial setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { 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(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { it.live(name, scenario("raw")) } function withProject( serverPath: ServerPath, options: { git?: boolean config?: Partial setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { 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( serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect, ) { return withProject(serverPath, { setup: writeStandardFiles }, run) } function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { 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( serverPath: ServerPath, options: { setup?: (dir: string) => Effect.Effect }, run: (input: LlmProjectFixture) => Effect.Effect, ) { 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, ".opencode", "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.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_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("opencode", "wrong") }, }) const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" })) const goodSdk = yield* client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "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: "session.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 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, 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, 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[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() const received = yield* Deferred.make() 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[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, }, } }), ), ) })