+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



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