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