SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause.
796 lines
29 KiB
TypeScript
796 lines
29 KiB
TypeScript
import { afterEach, describe, expect } from "bun:test"
|
|
import { ConfigProvider, Effect, Layer } from "effect"
|
|
import type * as Scope from "effect/Scope"
|
|
import { HttpRouter } from "effect/unstable/http"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { WithInstance } from "../../src/project/with-instance"
|
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
|
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 { errorMessage } from "../../src/util/error"
|
|
import { TestLLMServer } from "../lib/llm-server"
|
|
import path from "path"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
import { it } from "../lib/effect"
|
|
|
|
const original = {
|
|
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
|
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
|
}
|
|
|
|
type ServerPath = "default" | "raw"
|
|
type Sdk = ReturnType<typeof createOpencodeClient>
|
|
type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
|
type Captured = { status: number; data?: unknown; error?: unknown }
|
|
type ProjectFixture = { sdk: Sdk; directory: string }
|
|
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
|
|
|
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
|
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
|
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
|
if (serverPath === "default") return Server.Default().app
|
|
|
|
const handler = HttpRouter.toWebHandler(
|
|
ExperimentalHttpApiServer.routes.pipe(
|
|
Layer.provide(
|
|
ConfigProvider.layer(
|
|
ConfigProvider.fromUnknown({
|
|
OPENCODE_SERVER_PASSWORD: input?.password,
|
|
OPENCODE_SERVER_USERNAME: input?.username,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
{ disableLogger: true },
|
|
).handler
|
|
return {
|
|
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
|
request(input: string | URL | Request, init?: RequestInit) {
|
|
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
|
},
|
|
}
|
|
}
|
|
|
|
function client(
|
|
serverPath: ServerPath,
|
|
directory?: string,
|
|
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
|
) {
|
|
return createOpencodeClient({
|
|
baseUrl: "http://localhost",
|
|
directory,
|
|
headers: input?.headers,
|
|
fetch: serverFetch(serverPath, input),
|
|
})
|
|
}
|
|
|
|
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
|
const serverApp = app(serverPath, input)
|
|
return Object.assign(
|
|
async (request: RequestInfo | URL, init?: RequestInit) =>
|
|
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
|
{ preconnect: globalThis.fetch.preconnect },
|
|
) satisfies typeof globalThis.fetch
|
|
}
|
|
|
|
function authorization(username: string, password: string) {
|
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
|
}
|
|
|
|
function providerConfig(url: string) {
|
|
return {
|
|
formatter: false,
|
|
lsp: false,
|
|
provider: {
|
|
test: {
|
|
name: "Test",
|
|
id: "test",
|
|
env: [],
|
|
npm: "@ai-sdk/openai-compatible",
|
|
models: {
|
|
"test-model": {
|
|
id: "test-model",
|
|
name: "Test Model",
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: false,
|
|
tool_call: true,
|
|
release_date: "2025-01-01",
|
|
limit: { context: 100000, output: 10000 },
|
|
cost: { input: 0, output: 0 },
|
|
options: {},
|
|
},
|
|
},
|
|
options: {
|
|
apiKey: "test-key",
|
|
baseURL: url,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function call<T>(request: () => Promise<T>) {
|
|
return Effect.promise(request)
|
|
}
|
|
|
|
function capture(request: () => Promise<SdkResult>) {
|
|
return call(request).pipe(
|
|
Effect.map((result) => ({
|
|
status: result.response.status,
|
|
data: result.data,
|
|
error: result.error,
|
|
})),
|
|
)
|
|
}
|
|
|
|
function captureThrown(request: () => Promise<unknown>) {
|
|
return call(async () => {
|
|
try {
|
|
await request()
|
|
} catch (error) {
|
|
return error
|
|
}
|
|
})
|
|
}
|
|
|
|
function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
|
|
return call(request).pipe(
|
|
Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
|
|
Effect.asVoid,
|
|
)
|
|
}
|
|
|
|
function firstEvent(open: () => 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) {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
|
|
}
|
|
|
|
function array(value: unknown) {
|
|
return Array.isArray(value) ? value : []
|
|
}
|
|
|
|
function statuses(input: Record<string, Captured>) {
|
|
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
|
|
}
|
|
|
|
function firstPartText(value: unknown) {
|
|
return record(array(record(value).parts)[0]).text
|
|
}
|
|
|
|
function sessionTitles(value: unknown) {
|
|
return array(value)
|
|
.map((item) => record(item).title)
|
|
.filter((title): title is string => typeof title === "string")
|
|
.sort()
|
|
}
|
|
|
|
function resetState() {
|
|
return Effect.promise(async () => {
|
|
await disposeAllInstances()
|
|
await resetDatabase()
|
|
})
|
|
}
|
|
|
|
function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
|
|
it.live(name, effect)
|
|
}
|
|
|
|
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, Scope.Scope>) {
|
|
it.live(
|
|
name,
|
|
Effect.gen(function* () {
|
|
const standard = yield* scenario("default")
|
|
yield* resetState()
|
|
const raw = yield* scenario("raw")
|
|
expect(raw).toEqual(standard)
|
|
}),
|
|
)
|
|
}
|
|
|
|
function withProject<A, E, R>(
|
|
serverPath: ServerPath,
|
|
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(serverPath, tmp.path), directory: tmp.path })),
|
|
)
|
|
}
|
|
|
|
function withStandardProject<A, E, R>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
|
|
return withProject(serverPath, { setup: writeStandardFiles }, run)
|
|
}
|
|
|
|
function withFakeLlm<A, E, R>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
|
|
return Effect.gen(function* () {
|
|
const llm = yield* TestLLMServer
|
|
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
|
}).pipe(Effect.provide(TestLLMServer.layer))
|
|
}
|
|
|
|
function withFakeLlmProject<A, E, R>(
|
|
serverPath: ServerPath,
|
|
options: { setup?: (dir: string) => Effect.Effect<void> },
|
|
run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
|
|
) {
|
|
return Effect.gen(function* () {
|
|
const llm = yield* TestLLMServer
|
|
return yield* withProject(
|
|
serverPath,
|
|
{
|
|
config: providerConfig(llm.url),
|
|
setup: options.setup,
|
|
},
|
|
(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 writeProjectSkill(dir: string) {
|
|
return call(() =>
|
|
Bun.write(
|
|
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
|
|
`,
|
|
),
|
|
).pipe(Effect.asVoid)
|
|
}
|
|
|
|
function seedMessage(directory: string, sessionID: string) {
|
|
const id = SessionID.make(sessionID)
|
|
return call(
|
|
async () =>
|
|
await WithInstance.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)),
|
|
),
|
|
}),
|
|
)
|
|
}
|
|
|
|
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 = 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(() => 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)
|
|
}),
|
|
)
|
|
|
|
httpapi(
|
|
"uses the generated SDK for safe instance routes",
|
|
withProject("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),
|
|
])
|
|
}),
|
|
),
|
|
)
|
|
|
|
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
|
Effect.gen(function* () {
|
|
const sdk = 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) =>
|
|
firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
|
|
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
|
),
|
|
)
|
|
|
|
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
|
withStandardProject(serverPath, ({ sdk }) =>
|
|
firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).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 thrown = yield* captureThrown(() =>
|
|
validateSession({
|
|
url: "http://localhost",
|
|
directory,
|
|
sessionID,
|
|
fetch: serverFetch(serverPath),
|
|
}),
|
|
)
|
|
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
|
|
return errorMessage(thrown)
|
|
}),
|
|
),
|
|
)
|
|
|
|
httpapi(
|
|
"uses generated SDK basic auth behavior",
|
|
withStandardProject("raw", ({ directory }) =>
|
|
Effect.gen(function* () {
|
|
const missing = yield* capture(() =>
|
|
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
|
)
|
|
const bad = yield* capture(() =>
|
|
client("raw", directory, {
|
|
password: "secret",
|
|
headers: { authorization: authorization("opencode", "wrong") },
|
|
}).file.read({ path: "hello.txt" }),
|
|
)
|
|
const good = yield* capture(() =>
|
|
client("raw", directory, {
|
|
password: "secret",
|
|
headers: { authorization: authorization("opencode", "secret") },
|
|
}).file.read({ path: "hello.txt" }),
|
|
)
|
|
|
|
return {
|
|
statuses: statuses({ missing, bad, good }),
|
|
content: record(good.data).content,
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
|
withStandardProject(serverPath, ({ 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"),
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
|
|
withStandardProject(serverPath, ({ sdk }) =>
|
|
Effect.gen(function* () {
|
|
const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
|
|
const parentID = String(record(parent.data).id)
|
|
const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
|
|
const childID = String(record(child.data).id)
|
|
const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
|
|
const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
|
|
const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
|
|
const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
|
|
const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
|
|
const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
|
|
const status = yield* capture(() => sdk.session.status())
|
|
const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
|
|
const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
|
|
const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
|
|
const invalidCursor = yield* capture(() =>
|
|
sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }),
|
|
)
|
|
const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
|
|
const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
|
|
|
|
return {
|
|
statuses: statuses({
|
|
parent,
|
|
child,
|
|
get,
|
|
update,
|
|
roots,
|
|
all,
|
|
children,
|
|
todo,
|
|
status,
|
|
messages,
|
|
missingGet,
|
|
missingMessages,
|
|
invalidCursor,
|
|
deleted,
|
|
getDeleted,
|
|
}),
|
|
getTitle: record(get.data).title,
|
|
updatedTitle: record(update.data).title,
|
|
rootTitles: sessionTitles(roots.data),
|
|
allTitles: sessionTitles(all.data),
|
|
childCount: array(children.data).length,
|
|
todoCount: array(todo.data).length,
|
|
messageCount: array(messages.data).length,
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
|
|
withStandardProject(serverPath, ({ sdk, directory }) =>
|
|
Effect.gen(function* () {
|
|
const session = yield* capture(() => sdk.session.create({ title: "messages" }))
|
|
const sessionID = String(record(session.data).id)
|
|
const seeded = yield* seedMessage(directory, sessionID)
|
|
const list = yield* capture(() => sdk.session.messages({ sessionID }))
|
|
const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
|
|
const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
|
const partUpdate = yield* capture(() =>
|
|
sdk.part.update({
|
|
sessionID,
|
|
messageID: seeded.message.id,
|
|
partID: seeded.part.id,
|
|
part: { ...seeded.part, text: "updated message" } as NonNullable<
|
|
Parameters<Sdk["part"]["update"]>[0]["part"]
|
|
>,
|
|
}),
|
|
)
|
|
const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
|
const partDelete = yield* capture(() =>
|
|
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
|
|
)
|
|
const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
|
const deleteMessage = yield* capture(() =>
|
|
sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }),
|
|
)
|
|
const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
|
|
|
|
return {
|
|
statuses: statuses({
|
|
session,
|
|
list,
|
|
page,
|
|
message,
|
|
partUpdate,
|
|
updated,
|
|
partDelete,
|
|
withoutPart,
|
|
deleteMessage,
|
|
missingMessage,
|
|
}),
|
|
listCount: array(list.data).length,
|
|
pageCount: array(page.data).length,
|
|
initialText: firstPartText(message.data),
|
|
updatedText: firstPartText(updated.data),
|
|
partCountAfterDelete: array(record(withoutPart.data).parts).length,
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
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 async 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.promptAsync({
|
|
sessionID,
|
|
agent: "build",
|
|
model: { providerID: "test", modelID: "test-model" },
|
|
parts: [{ type: "text", text: "hello skill context" }],
|
|
}),
|
|
)
|
|
yield* llm.wait(1)
|
|
const inputs = yield* llm.inputs
|
|
|
|
expect(session.status).toBe(200)
|
|
expect(prompt.status).toBe(204)
|
|
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, { 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 === directory,
|
|
},
|
|
after: {
|
|
vcs: record(after.data).vcs,
|
|
worktreeSelected: record(after.data).worktree === directory,
|
|
},
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
})
|