test(httpapi): clean up SDK parity tests

This commit is contained in:
Kit Langton
2026-04-29 09:46:17 -04:00
parent df147b65fd
commit d3df8e1180
+299 -287
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import type * as Scope from "effect/Scope"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -7,20 +8,26 @@ import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session/message-v2"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Config } from "@/config/config"
import { Session as SessionNs } from "@/session/session" import { Session as SessionNs } from "@/session/session"
import { TestLLMServer } from "../lib/llm-server" import { TestLLMServer } from "../lib/llm-server"
import path from "path" import path from "path"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const original = { const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
} }
type Backend = "legacy" | "httpapi" type Backend = "legacy" | "httpapi"
type Sdk = ReturnType<typeof createOpencodeClient> type Sdk = ReturnType<typeof createOpencodeClient>
type SdkResult = { response: Response; data?: unknown; error?: unknown } 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"] }
function app(backend: Backend, input?: { password?: string; username?: string }) { function app(backend: Backend, input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
@@ -85,17 +92,35 @@ function providerConfig(url: string) {
} }
} }
async function expectStatus(result: Promise<{ response: Response }>, status: number) { function call<T>(request: () => Promise<T>) {
expect((await result).response.status).toBe(status) return Effect.promise(request)
} }
async function capture(result: Promise<SdkResult>) { function capture(request: () => Promise<SdkResult>) {
const response = await result return call(request).pipe(
return { Effect.map((result) => ({
status: response.response.status, status: result.response.status,
data: response.data, data: result.data,
error: response.error, error: result.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: () => Promise<{ stream: AsyncIterator<unknown> }>) {
return Effect.acquireRelease(
call(open),
(events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
).pipe(
Effect.flatMap((events) => call(() => events.stream.next())),
Effect.map((result) => result.value),
)
} }
function record(value: unknown) { function record(value: unknown) {
@@ -106,7 +131,7 @@ function array(value: unknown) {
return Array.isArray(value) ? value : [] return Array.isArray(value) ? value : []
} }
function statuses(input: Record<string, Awaited<ReturnType<typeof capture>>>) { function statuses(input: Record<string, Captured>) {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status])) return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
} }
@@ -121,75 +146,91 @@ function sessionTitles(value: unknown) {
.sort() .sort()
} }
async function runSession<A, E>(directory: string, effect: Effect.Effect<A, E, SessionNs.Service>) { function resetState() {
return Instance.provide({ return Effect.promise(async () => {
directory, await Instance.disposeAll()
fn: () => Effect.runPromise(effect.pipe(Effect.provide(SessionNs.defaultLayer))), await resetDatabase()
}) })
} }
async function seedMessage(directory: string, sessionID: string) { function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
const id = SessionID.make(sessionID) it.live(name, effect)
return runSession( }
directory,
SessionNs.Service.use((svc) => function parity<A, E>(name: string, scenario: (backend: Backend) => Effect.Effect<A, E, Scope.Scope>) {
Effect.gen(function* () { it.live(
const message = yield* svc.updateMessage({ name,
id: MessageID.ascending(), Effect.gen(function* () {
sessionID: id, const legacy = yield* scenario("legacy")
role: "user", yield* resetState()
time: { created: Date.now() }, const httpapi = yield* scenario("httpapi")
agent: "test", expect(httpapi).toEqual(legacy)
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, }),
tools: {},
mode: "",
} as unknown as MessageV2.Info)
const part = yield* svc.updatePart({
id: PartID.ascending(),
sessionID: id,
messageID: message.id,
type: "text",
text: "seeded message",
})
return { message, part }
}),
),
) )
} }
async function compareBackends<T>(scenario: (backend: Backend) => Promise<T>) { function withProject<A, E, R>(
const legacy = await scenario("legacy")
await Instance.disposeAll()
await resetDatabase()
const httpapi = await scenario("httpapi")
expect(httpapi).toEqual(legacy)
}
async function withTmp<T>(backend: Backend, fn: (input: { sdk: Sdk; directory: string }) => Promise<T>) {
await using tmp = await tmpdir({
git: true,
config: { formatter: false, lsp: false },
init: async (dir) => {
await Bun.write(path.join(dir, "hello.txt"), "hello")
await Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")
},
})
return fn({ sdk: client(backend, tmp.path), directory: tmp.path })
}
async function withFakeLlm<T>(
backend: Backend, backend: Backend,
fn: (input: { sdk: Sdk; directory: string; llm: TestLLMServer["Service"] }) => Promise<T>, options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
) { ) {
return Effect.runPromise( return Effect.acquireRelease(
Effect.gen(function* () { call(() => tmpdir({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })),
const llm = yield* TestLLMServer (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore),
const tmp = yield* Effect.acquireRelease( ).pipe(
Effect.promise(() => tmpdir({ git: true, config: providerConfig(llm.url) })), Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })),
) )
return yield* Effect.promise(() => fn({ sdk: client(backend, tmp.path), directory: tmp.path, llm })) }
}).pipe(Effect.scoped, Effect.provide(TestLLMServer.layer)),
function withStandardProject<A, E, R>(backend: Backend, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
return withProject(backend, { setup: writeStandardFiles }, run)
}
function withFakeLlm<A, E, R>(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
}).pipe(Effect.provide(TestLLMServer.layer))
}
function writeStandardFiles(dir: string) {
return Effect.all([
call(() => Bun.write(path.join(dir, "hello.txt"), "hello")),
call(() => Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")),
]).pipe(Effect.asVoid)
}
function seedMessage(directory: string, sessionID: string) {
const id = SessionID.make(sessionID)
return call(async () =>
await Instance.provide({
directory,
fn: () =>
Effect.runPromise(
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: ProviderID.make("test"), modelID: ModelID.make("test") },
tools: {},
} satisfies MessageV2.User)
const part = yield* svc.updatePart({
id: PartID.ascending(),
sessionID: id,
messageID: message.id,
type: "text",
text: "seeded message",
})
return { message, part }
}),
).pipe(Effect.provide(SessionNs.defaultLayer)),
),
}),
) )
} }
@@ -202,113 +243,91 @@ afterEach(async () => {
}) })
describe("HttpApi SDK", () => { describe("HttpApi SDK", () => {
test("uses the generated SDK for global and control routes", async () => { httpapi(
const sdk = client("httpapi") "uses the generated SDK for global and control routes",
const health = await sdk.global.health() Effect.gen(function* () {
const sdk = client("httpapi")
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.response.status).toBe(200)
expect(health.data).toMatchObject({ healthy: true }) expect(health.data).toMatchObject({ healthy: true })
expect(yield* firstEvent(() => sdk.global.event({ signal: AbortSignal.timeout(1_000) }))).toMatchObject({
payload: { type: "server.connected" },
})
expect(log.response.status).toBe(200)
expect(log.data).toBe(true)
yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400)
}),
)
const events = await sdk.global.event({ signal: AbortSignal.timeout(1_000) }) httpapi(
try { "uses the generated SDK for safe instance routes",
const first = await events.stream.next() withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) =>
expect(first.value).toMatchObject({ payload: { type: "server.connected" } }) Effect.gen(function* () {
} finally { const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
await events.stream.return(undefined) const session = yield* call(() => sdk.session.create({ title: "sdk" }))
} const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 }))
const log = await sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }) expect(file.response.status).toBe(200)
expect(log.response.status).toBe(200) expect(file.data).toMatchObject({ content: "hello" })
expect(log.data).toBe(true) 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)
await expectStatus(sdk.auth.set({ providerID: "test" }), 400) 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),
])
}),
),
)
test("uses the generated SDK for safe instance routes", async () => { parity("matches generated SDK global and control behavior across backends", (backend) =>
await using tmp = await tmpdir({ Effect.gen(function* () {
config: { formatter: false, lsp: false },
init: (dir) => Bun.write(path.join(dir, "hello.txt"), "hello"),
})
const sdk = client("httpapi", tmp.path)
const file = await sdk.file.read({ path: "hello.txt" })
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ content: "hello" })
const session = await sdk.session.create({ title: "sdk" })
expect(session.response.status).toBe(200)
expect(session.data).toMatchObject({ title: "sdk" })
const listed = await sdk.session.list({ roots: true, limit: 10 })
expect(listed.response.status).toBe(200)
expect(listed.data?.map((item) => item.id)).toContain(session.data?.id)
await Promise.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),
])
})
test("matches generated SDK global and control behavior across backends", async () => {
await compareBackends(async (backend) => {
const sdk = client(backend) const sdk = client(backend)
const health = await capture(sdk.global.health()) const health = yield* capture(() => sdk.global.health())
const log = await capture(sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
const invalidAuth = await capture(sdk.auth.set({ providerID: "test" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
return { return {
statuses: statuses({ health, log, invalidAuth }), statuses: statuses({ health, log, invalidAuth }),
health: record(health.data).healthy, health: record(health.data).healthy,
log: log.data, log: log.data,
} }
}) }),
}) )
test("matches generated SDK global event stream across backends", async () => { parity("matches generated SDK global event stream across backends", (backend) =>
await compareBackends(async (backend) => { firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
const events = await client(backend).global.event({ signal: AbortSignal.timeout(1_000) }) Effect.map((event) => ({ type: record(record(event).payload).type })),
try { ),
const first = await events.stream.next() )
return {
type: record(record(first.value).payload).type,
}
} finally {
await events.stream.return(undefined)
}
})
})
test("matches generated SDK instance event stream across backends", async () => { parity("matches generated SDK instance event stream across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk }) =>
withTmp(backend, async ({ sdk }) => { firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe(
const events = await sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) }) Effect.map((event) => ({ type: record(record(event).payload).type })),
try { ),
const first = await events.stream.next() ),
return { )
type: record(record(first.value).payload).type,
}
} finally {
await events.stream.return(undefined)
}
}),
)
})
test("matches generated SDK basic auth behavior across backends", async () => { parity("matches generated SDK basic auth behavior across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ directory }) =>
withTmp(backend, async ({ directory }) => { Effect.gen(function* () {
const missing = await capture( const missing = yield* capture(() =>
client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }),
) )
const bad = await capture( const bad = yield* capture(() =>
client(backend, directory, { client(backend, directory, {
password: "secret", password: "secret",
headers: { authorization: authorization("opencode", "wrong") }, headers: { authorization: authorization("opencode", "wrong") },
}).file.read({ path: "hello.txt" }), }).file.read({ path: "hello.txt" }),
) )
const good = await capture( const good = yield* capture(() =>
client(backend, directory, { client(backend, directory, {
password: "secret", password: "secret",
headers: { authorization: authorization("opencode", "secret") }, headers: { authorization: authorization("opencode", "secret") },
@@ -320,28 +339,28 @@ describe("HttpApi SDK", () => {
content: record(good.data).content, content: record(good.data).content,
} }
}), }),
) ),
}) )
test("matches generated SDK instance read routes across backends", async () => { parity("matches generated SDK instance read routes across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk, directory }) =>
withTmp(backend, async ({ sdk, directory }) => { Effect.gen(function* () {
const project = await capture(sdk.project.current()) const project = yield* capture(() => sdk.project.current())
const projects = await capture(sdk.project.list()) const projects = yield* capture(() => sdk.project.list())
const paths = await capture(sdk.path.get()) const paths = yield* capture(() => sdk.path.get())
const config = await capture(sdk.config.get()) const config = yield* capture(() => sdk.config.get())
const providers = await capture(sdk.config.providers()) const providers = yield* capture(() => sdk.config.providers())
const file = await capture(sdk.file.read({ path: "hello.txt" })) const file = yield* capture(() => sdk.file.read({ path: "hello.txt" }))
const files = await capture(sdk.file.list({ path: "." })) const files = yield* capture(() => sdk.file.list({ path: "." }))
const fileStatus = await capture(sdk.file.status()) const fileStatus = yield* capture(() => sdk.file.status())
const findFiles = await capture(sdk.find.files({ query: "hello", limit: 10 })) const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 }))
const findText = await capture(sdk.find.text({ pattern: "sdk-parity" })) const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" }))
const agents = await capture(sdk.app.agents()) const agents = yield* capture(() => sdk.app.agents())
const skills = await capture(sdk.app.skills()) const skills = yield* capture(() => sdk.app.skills())
const tools = await capture(sdk.tool.ids()) const tools = yield* capture(() => sdk.tool.ids())
const vcs = await capture(sdk.vcs.get()) const vcs = yield* capture(() => sdk.vcs.get())
const formatter = await capture(sdk.formatter.status()) const formatter = yield* capture(() => sdk.formatter.status())
const lsp = await capture(sdk.lsp.status()) const lsp = yield* capture(() => sdk.lsp.status())
return { return {
statuses: statuses({ statuses: statuses({
@@ -362,12 +381,8 @@ describe("HttpApi SDK", () => {
formatter, formatter,
lsp, lsp,
}), }),
project: { project: { worktreeSelected: record(project.data).worktree === directory },
worktreeSelected: record(project.data).worktree === directory, paths: { cwdSelected: record(paths.data).cwd === directory },
},
paths: {
cwdSelected: record(paths.data).cwd === directory,
},
file: record(file.data).content, file: record(file.data).content,
hasProject: array(projects.data).length > 0, hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"), foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
@@ -375,29 +390,29 @@ describe("HttpApi SDK", () => {
listedFile: JSON.stringify(files.data).includes("hello.txt"), listedFile: JSON.stringify(files.data).includes("hello.txt"),
} }
}), }),
) ),
}) )
test("matches generated SDK session lifecycle routes across backends", async () => { parity("matches generated SDK session lifecycle routes across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk }) =>
withTmp(backend, async ({ sdk }) => { Effect.gen(function* () {
const parent = await capture(sdk.session.create({ title: "parent" })) const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
const parentID = String(record(parent.data).id) const parentID = String(record(parent.data).id)
const child = await capture(sdk.session.create({ title: "child", parentID })) const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
const childID = String(record(child.data).id) const childID = String(record(child.data).id)
const get = await capture(sdk.session.get({ sessionID: parentID })) const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
const update = await capture(sdk.session.update({ sessionID: parentID, title: "renamed" })) const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
const roots = await capture(sdk.session.list({ roots: true, limit: 10 })) const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
const all = await capture(sdk.session.list({ roots: false, limit: 10 })) const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
const children = await capture(sdk.session.children({ sessionID: parentID })) const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
const todo = await capture(sdk.session.todo({ sessionID: parentID })) const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
const status = await capture(sdk.session.status()) const status = yield* capture(() => sdk.session.status())
const messages = await capture(sdk.session.messages({ sessionID: parentID })) const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
const missingGet = await capture(sdk.session.get({ sessionID: "ses_missing" })) const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
const missingMessages = await capture(sdk.session.messages({ sessionID: "ses_missing", limit: 2 })) const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
const invalidCursor = await capture(sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" })) const invalidCursor = yield* capture(() => sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }))
const deleted = await capture(sdk.session.delete({ sessionID: childID })) const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
const getDeleted = await capture(sdk.session.get({ sessionID: childID })) const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
return { return {
statuses: statuses({ statuses: statuses({
@@ -426,36 +441,33 @@ describe("HttpApi SDK", () => {
messageCount: array(messages.data).length, messageCount: array(messages.data).length,
} }
}), }),
) ),
}) )
test("matches generated SDK session message and part routes across backends", async () => { parity("matches generated SDK session message and part routes across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk, directory }) =>
withTmp(backend, async ({ sdk, directory }) => { Effect.gen(function* () {
const session = await capture(sdk.session.create({ title: "messages" })) const session = yield* capture(() => sdk.session.create({ title: "messages" }))
const sessionID = String(record(session.data).id) const sessionID = String(record(session.data).id)
const seeded = await seedMessage(directory, sessionID) const seeded = yield* seedMessage(directory, sessionID)
const list = await capture(sdk.session.messages({ sessionID })) const list = yield* capture(() => sdk.session.messages({ sessionID }))
const page = await capture(sdk.session.messages({ sessionID, limit: 1 })) const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
const message = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partUpdate = await capture( const partUpdate = yield* capture(() =>
sdk.part.update({ sdk.part.update({
sessionID, sessionID,
messageID: seeded.message.id, messageID: seeded.message.id,
partID: seeded.part.id, partID: seeded.part.id,
part: { part: { ...seeded.part, text: "updated message" } as NonNullable<Parameters<Sdk["part"]["update"]>[0]["part"]>,
...seeded.part,
text: "updated message",
} as NonNullable<Parameters<Sdk["part"]["update"]>[0]["part"]>,
}), }),
) )
const updated = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partDelete = await capture( const partDelete = yield* capture(() =>
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }), sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
) )
const withoutPart = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const deleteMessage = await capture(sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id })) const deleteMessage = yield* capture(() => sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }))
const missingMessage = await capture(sdk.session.message({ sessionID, messageID: seeded.message.id })) const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
return { return {
statuses: statuses({ statuses: statuses({
@@ -477,15 +489,15 @@ describe("HttpApi SDK", () => {
partCountAfterDelete: array(record(withoutPart.data).parts).length, partCountAfterDelete: array(record(withoutPart.data).parts).length,
} }
}), }),
) ),
}) )
test("matches generated SDK prompt no-reply routes across backends", async () => { parity("matches generated SDK prompt no-reply routes across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk }) =>
withTmp(backend, async ({ sdk }) => { Effect.gen(function* () {
const session = await capture(sdk.session.create({ title: "prompt" })) const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
const sessionID = String(record(session.data).id) const sessionID = String(record(session.data).id)
const prompt = await capture( const prompt = yield* capture(() =>
sdk.session.prompt({ sdk.session.prompt({
sessionID, sessionID,
agent: "build", agent: "build",
@@ -493,7 +505,7 @@ describe("HttpApi SDK", () => {
parts: [{ type: "text", text: "hello" }], parts: [{ type: "text", text: "hello" }],
}), }),
) )
const asyncPrompt = await capture( const asyncPrompt = yield* capture(() =>
sdk.session.promptAsync({ sdk.session.promptAsync({
sessionID, sessionID,
agent: "build", agent: "build",
@@ -501,7 +513,7 @@ describe("HttpApi SDK", () => {
parts: [{ type: "text", text: "async hello" }], parts: [{ type: "text", text: "async hello" }],
}), }),
) )
const messages = await capture(sdk.session.messages({ sessionID })) const messages = yield* capture(() => sdk.session.messages({ sessionID }))
return { return {
statuses: statuses({ session, prompt, asyncPrompt, messages }), statuses: statuses({ session, prompt, asyncPrompt, messages }),
@@ -514,21 +526,21 @@ describe("HttpApi SDK", () => {
.sort(), .sort(),
} }
}), }),
) ),
}) )
test("matches generated SDK prompt streaming through fake LLM across backends", async () => { parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) =>
await compareBackends((backend) => withFakeLlm(backend, ({ sdk, llm }) =>
withFakeLlm(backend, async ({ sdk, llm }) => { Effect.gen(function* () {
await Effect.runPromise(llm.text("fake world", { usage: { input: 11, output: 7 } })) yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
const session = await capture( const session = yield* capture(() =>
sdk.session.create({ sdk.session.create({
title: "llm prompt", title: "llm prompt",
permission: [{ permission: "*", pattern: "*", action: "allow" }], permission: [{ permission: "*", pattern: "*", action: "allow" }],
}), }),
) )
const sessionID = String(record(session.data).id) const sessionID = String(record(session.data).id)
const prompt = await capture( const prompt = yield* capture(() =>
sdk.session.prompt({ sdk.session.prompt({
sessionID, sessionID,
agent: "build", agent: "build",
@@ -536,8 +548,8 @@ describe("HttpApi SDK", () => {
parts: [{ type: "text", text: "hello llm" }], parts: [{ type: "text", text: "hello llm" }],
}), }),
) )
const messages = await capture(sdk.session.messages({ sessionID })) const messages = yield* capture(() => sdk.session.messages({ sessionID }))
const inputs = await Effect.runPromise(llm.inputs) const inputs = yield* llm.inputs
return { return {
statuses: statuses({ session, prompt, messages }), statuses: statuses({ session, prompt, messages }),
@@ -548,26 +560,26 @@ describe("HttpApi SDK", () => {
userText: JSON.stringify(messages.data).includes("hello llm"), userText: JSON.stringify(messages.data).includes("hello llm"),
} }
}), }),
) ),
}) )
test("matches generated SDK TUI validation and command routes across backends", async () => { parity("matches generated SDK TUI validation and command routes across backends", (backend) =>
await compareBackends((backend) => withStandardProject(backend, ({ sdk }) =>
withTmp(backend, async ({ sdk }) => { Effect.gen(function* () {
const session = await capture(sdk.session.create({ title: "tui" })) const session = yield* capture(() => sdk.session.create({ title: "tui" }))
const sessionID = String(record(session.data).id) const sessionID = String(record(session.data).id)
const appendPrompt = await capture(sdk.tui.appendPrompt({ text: "hello" })) const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" }))
const openHelp = await capture(sdk.tui.openHelp()) const openHelp = yield* capture(() => sdk.tui.openHelp())
const openSessions = await capture(sdk.tui.openSessions()) const openSessions = yield* capture(() => sdk.tui.openSessions())
const openThemes = await capture(sdk.tui.openThemes()) const openThemes = yield* capture(() => sdk.tui.openThemes())
const openModels = await capture(sdk.tui.openModels()) const openModels = yield* capture(() => sdk.tui.openModels())
const submitPrompt = await capture(sdk.tui.submitPrompt()) const submitPrompt = yield* capture(() => sdk.tui.submitPrompt())
const clearPrompt = await capture(sdk.tui.clearPrompt()) const clearPrompt = yield* capture(() => sdk.tui.clearPrompt())
const executeCommand = await capture(sdk.tui.executeCommand({ command: "session_new" })) const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" }))
const showToast = await capture(sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" })) const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
const selectSession = await capture(sdk.tui.selectSession({ sessionID })) const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID }))
const missingSession = await capture(sdk.tui.selectSession({ sessionID: "ses_missing" })) const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" }))
const invalidSession = await capture(sdk.tui.selectSession({ sessionID: "invalid_session_id" })) const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
return { return {
statuses: statuses({ statuses: statuses({
@@ -599,32 +611,32 @@ describe("HttpApi SDK", () => {
}, },
} }
}), }),
) ),
}) )
test("matches generated SDK project git initialization across backends", async () => { parity("matches generated SDK project git initialization across backends", (backend) =>
await compareBackends(async (backend) => { withProject(backend, { git: false }, ({ sdk, directory }) =>
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) Effect.gen(function* () {
const sdk = client(backend, tmp.path) const before = yield* capture(() => sdk.project.current())
const before = await capture(sdk.project.current()) const init = yield* capture(() => sdk.project.initGit())
const init = await capture(sdk.project.initGit()) const after = yield* capture(() => sdk.project.current())
const after = await capture(sdk.project.current())
return { return {
statuses: statuses({ before, init, after }), statuses: statuses({ before, init, after }),
before: { before: {
vcs: record(before.data).vcs ?? null, vcs: record(before.data).vcs ?? null,
worktree: record(before.data).worktree, worktree: record(before.data).worktree,
}, },
init: { init: {
vcs: record(init.data).vcs, vcs: record(init.data).vcs,
worktreeSelected: record(init.data).worktree === tmp.path, worktreeSelected: record(init.data).worktree === directory,
}, },
after: { after: {
vcs: record(after.data).vcs, vcs: record(after.data).vcs,
worktreeSelected: record(after.data).worktree === tmp.path, worktreeSelected: record(after.data).worktree === directory,
}, },
} }
}) }),
}) ),
)
}) })