httpapi-sdk.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { ConfigProvider, Effect, Layer } from "effect"
  3. import type * as Scope from "effect/Scope"
  4. import { HttpRouter } from "effect/unstable/http"
  5. import { ChildProcessSpawner } from "effect/unstable/process"
  6. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  7. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  8. import { Flag } from "@opencode-ai/core/flag/flag"
  9. import { createOpencodeClient } from "@opencode-ai/sdk/v2"
  10. import { validateSession } from "../../src/cli/cmd/tui/validate-session"
  11. import { InstanceBootstrap } from "../../src/project/bootstrap-service"
  12. import { InstanceStore } from "../../src/project/instance-store"
  13. import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
  14. import { Server } from "../../src/server/server"
  15. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  16. import { MessageV2 } from "../../src/session/message-v2"
  17. import { ModelID, ProviderID } from "../../src/provider/schema"
  18. import type { Config } from "@/config/config"
  19. import { Session as SessionNs } from "@/session/session"
  20. import { errorMessage } from "../../src/util/error"
  21. import { TestLLMServer } from "../lib/llm-server"
  22. import path from "path"
  23. import { resetDatabase } from "../fixture/db"
  24. import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
  25. import { testEffect } from "../lib/effect"
  26. const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
  27. const it = testEffect(
  28. Layer.mergeAll(
  29. AppFileSystem.defaultLayer,
  30. CrossSpawnSpawner.defaultLayer,
  31. InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
  32. ),
  33. )
  34. const original = {
  35. OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
  36. OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
  37. }
  38. type ServerPath = "default" | "raw"
  39. type Sdk = ReturnType<typeof createOpencodeClient>
  40. type SdkResult = { response: Response; data?: unknown; error?: unknown }
  41. type Captured = { status: number; data?: unknown; error?: unknown }
  42. type ProjectFixture = { sdk: Sdk; directory: string }
  43. type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
  44. type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
  45. type TestScope = Scope.Scope | TestServices
  46. function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
  47. Flag.OPENCODE_SERVER_PASSWORD = input?.password
  48. Flag.OPENCODE_SERVER_USERNAME = input?.username
  49. if (serverPath === "default") return Server.Default().app
  50. const handler = HttpRouter.toWebHandler(
  51. ExperimentalHttpApiServer.routes.pipe(
  52. Layer.provide(
  53. ConfigProvider.layer(
  54. ConfigProvider.fromUnknown({
  55. OPENCODE_SERVER_PASSWORD: input?.password,
  56. OPENCODE_SERVER_USERNAME: input?.username,
  57. }),
  58. ),
  59. ),
  60. ),
  61. { disableLogger: true },
  62. ).handler
  63. return {
  64. fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
  65. request(input: string | URL | Request, init?: RequestInit) {
  66. return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
  67. },
  68. }
  69. }
  70. function client(
  71. serverPath: ServerPath,
  72. directory?: string,
  73. input?: { password?: string; username?: string; headers?: Record<string, string> },
  74. ) {
  75. return createOpencodeClient({
  76. baseUrl: "http://localhost",
  77. directory,
  78. headers: input?.headers,
  79. fetch: serverFetch(serverPath, input),
  80. })
  81. }
  82. function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
  83. const serverApp = app(serverPath, input)
  84. return Object.assign(
  85. async (request: RequestInfo | URL, init?: RequestInit) =>
  86. await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
  87. { preconnect: globalThis.fetch.preconnect },
  88. ) satisfies typeof globalThis.fetch
  89. }
  90. function authorization(username: string, password: string) {
  91. return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
  92. }
  93. function providerConfig(url: string) {
  94. return {
  95. formatter: false,
  96. lsp: false,
  97. provider: {
  98. test: {
  99. name: "Test",
  100. id: "test",
  101. env: [],
  102. npm: "@ai-sdk/openai-compatible",
  103. models: {
  104. "test-model": {
  105. id: "test-model",
  106. name: "Test Model",
  107. attachment: false,
  108. reasoning: false,
  109. temperature: false,
  110. tool_call: true,
  111. release_date: "2025-01-01",
  112. limit: { context: 100000, output: 10000 },
  113. cost: { input: 0, output: 0 },
  114. options: {},
  115. },
  116. },
  117. options: {
  118. apiKey: "test-key",
  119. baseURL: url,
  120. },
  121. },
  122. },
  123. }
  124. }
  125. function call<T>(request: () => Promise<T>) {
  126. return Effect.promise(request)
  127. }
  128. function capture(request: () => Promise<SdkResult>) {
  129. return call(request).pipe(
  130. Effect.map((result) => ({
  131. status: result.response.status,
  132. data: result.data,
  133. error: result.error,
  134. })),
  135. )
  136. }
  137. function captureThrown(request: () => Promise<unknown>) {
  138. return call(async () => {
  139. try {
  140. await request()
  141. } catch (error) {
  142. return error
  143. }
  144. })
  145. }
  146. function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
  147. return call(request).pipe(
  148. Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
  149. Effect.asVoid,
  150. )
  151. }
  152. function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
  153. return Effect.acquireRelease(
  154. Effect.sync(() => new AbortController()),
  155. (controller) => Effect.sync(() => controller.abort()),
  156. ).pipe(
  157. Effect.flatMap((controller) =>
  158. Effect.acquireRelease(call(() => open(controller.signal)), (events) =>
  159. call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
  160. ).pipe(
  161. Effect.flatMap((events) =>
  162. call(() => events.stream.next()).pipe(
  163. Effect.timeoutOrElse({
  164. duration: "1 second",
  165. orElse: () => Effect.fail(new Error("timed out waiting for SDK event")),
  166. }),
  167. ),
  168. ),
  169. Effect.map((result) => result.value),
  170. ),
  171. ),
  172. )
  173. }
  174. function record(value: unknown) {
  175. return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
  176. }
  177. function array(value: unknown) {
  178. return Array.isArray(value) ? value : []
  179. }
  180. function statuses(input: Record<string, Captured>) {
  181. return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
  182. }
  183. function firstPartText(value: unknown) {
  184. return record(array(record(value).parts)[0]).text
  185. }
  186. function sessionTitles(value: unknown) {
  187. return array(value)
  188. .map((item) => record(item).title)
  189. .filter((title): title is string => typeof title === "string")
  190. .sort()
  191. }
  192. function resetState() {
  193. return Effect.promise(async () => {
  194. await disposeAllInstances()
  195. await resetDatabase()
  196. })
  197. }
  198. function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
  199. it.live(name, effect)
  200. }
  201. function httpapiInstance<A, E>(
  202. name: string,
  203. options: {
  204. serverPath: ServerPath
  205. git?: boolean
  206. config?: Partial<Config.Info>
  207. setup?: (dir: string) => Effect.Effect<void, E, TestServices>
  208. },
  209. run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
  210. ) {
  211. it.instance(
  212. name,
  213. Effect.gen(function* () {
  214. const instance = yield* TestInstance
  215. yield* (options.setup?.(instance.directory) ?? Effect.void)
  216. return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
  217. }),
  218. { git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
  219. )
  220. }
  221. function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
  222. it.live(
  223. name,
  224. Effect.gen(function* () {
  225. const standard = yield* scenario("default")
  226. yield* resetState()
  227. const raw = yield* scenario("raw")
  228. expect(raw).toEqual(standard)
  229. }),
  230. )
  231. }
  232. function withProject<A, E, E2 = never>(
  233. serverPath: ServerPath,
  234. options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void, E2, TestServices> },
  235. run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
  236. ) {
  237. return Effect.gen(function* () {
  238. const directory = yield* tmpdirScoped({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })
  239. yield* (options.setup?.(directory) ?? Effect.void)
  240. return yield* run({ sdk: client(serverPath, directory), directory })
  241. })
  242. }
  243. function withStandardProject<A, E>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>) {
  244. return withProject(serverPath, { setup: writeStandardFiles }, run)
  245. }
  246. function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
  247. return Effect.gen(function* () {
  248. const llm = yield* TestLLMServer
  249. return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
  250. }).pipe(Effect.provide(TestLLMServer.layer))
  251. }
  252. function withFakeLlmProject<A, E>(
  253. serverPath: ServerPath,
  254. options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
  255. run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
  256. ) {
  257. return Effect.gen(function* () {
  258. const llm = yield* TestLLMServer
  259. return yield* withProject(
  260. serverPath,
  261. {
  262. config: providerConfig(llm.url),
  263. setup: options.setup,
  264. },
  265. (input) => run({ ...input, llm }),
  266. )
  267. }).pipe(Effect.provide(TestLLMServer.layer))
  268. }
  269. function writeStandardFiles(dir: string) {
  270. return AppFileSystem.Service.use((fs) =>
  271. Effect.all([
  272. fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
  273. fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
  274. ]).pipe(Effect.asVoid),
  275. )
  276. }
  277. function writeProjectSkill(dir: string) {
  278. return AppFileSystem.Service.use((fs) =>
  279. fs.writeWithDirs(
  280. path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
  281. `---
  282. name: project-rest-skill
  283. description: A project skill visible to REST API prompts.
  284. ---
  285. # Project REST Skill
  286. `,
  287. ),
  288. )
  289. }
  290. function seedMessage(directory: string, sessionID: string) {
  291. const id = SessionID.make(sessionID)
  292. return InstanceStore.Service.use((store) =>
  293. store.provide(
  294. { directory },
  295. SessionNs.Service.use((svc) =>
  296. Effect.gen(function* () {
  297. const message = yield* svc.updateMessage({
  298. id: MessageID.ascending(),
  299. sessionID: id,
  300. role: "user",
  301. time: { created: Date.now() },
  302. agent: "test",
  303. model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
  304. tools: {},
  305. } satisfies MessageV2.User)
  306. const part = yield* svc.updatePart({
  307. id: PartID.ascending(),
  308. sessionID: id,
  309. messageID: message.id,
  310. type: "text",
  311. text: "seeded message",
  312. })
  313. return { message, part }
  314. }),
  315. ).pipe(Effect.provide(SessionNs.defaultLayer)),
  316. ),
  317. )
  318. }
  319. afterEach(async () => {
  320. Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
  321. Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
  322. await disposeAllInstances()
  323. await resetDatabase()
  324. })
  325. describe("HttpApi SDK", () => {
  326. httpapi(
  327. "uses the generated SDK for global and control routes",
  328. Effect.gen(function* () {
  329. const sdk = client("raw")
  330. const health = yield* call(() => sdk.global.health())
  331. const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
  332. expect(health.response.status).toBe(200)
  333. expect(health.data).toMatchObject({ healthy: true })
  334. expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
  335. payload: { type: "server.connected" },
  336. })
  337. expect(log.response.status).toBe(200)
  338. expect(log.data).toBe(true)
  339. yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400)
  340. }),
  341. )
  342. httpapiInstance(
  343. "uses the generated SDK for safe instance routes",
  344. { serverPath: "raw", git: false, setup: writeStandardFiles },
  345. ({ sdk }) =>
  346. Effect.gen(function* () {
  347. const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
  348. const session = yield* call(() => sdk.session.create({ title: "sdk" }))
  349. const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 }))
  350. expect(file.response.status).toBe(200)
  351. expect(file.data).toMatchObject({ content: "hello" })
  352. expect(session.response.status).toBe(200)
  353. expect(session.data).toMatchObject({ title: "sdk" })
  354. expect(listed.response.status).toBe(200)
  355. expect(listed.data?.map((item) => item.id)).toContain(session.data?.id)
  356. yield* Effect.all([
  357. expectStatus(() => sdk.project.current(), 200),
  358. expectStatus(() => sdk.config.get(), 200),
  359. expectStatus(() => sdk.config.providers(), 200),
  360. expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
  361. ])
  362. }),
  363. )
  364. serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
  365. Effect.gen(function* () {
  366. const sdk = client(serverPath)
  367. const health = yield* capture(() => sdk.global.health())
  368. const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
  369. const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
  370. return {
  371. statuses: statuses({ health, log, invalidAuth }),
  372. health: record(health.data).healthy,
  373. log: log.data,
  374. }
  375. }),
  376. )
  377. serverPathParity("matches generated SDK global event stream", (serverPath) =>
  378. firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
  379. Effect.map((event) => ({ type: record(record(event).payload).type })),
  380. ),
  381. )
  382. serverPathParity("matches generated SDK instance event stream", (serverPath) =>
  383. withStandardProject(serverPath, ({ sdk }) =>
  384. firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
  385. Effect.map((event) => ({ type: record(record(event).payload).type })),
  386. ),
  387. ),
  388. )
  389. serverPathParity("matches generated SDK missing session errors", (serverPath) =>
  390. withStandardProject(serverPath, ({ sdk }) =>
  391. Effect.gen(function* () {
  392. const sessionID = "ses_missing"
  393. const expected = {
  394. name: "NotFoundError",
  395. data: { message: `Session not found: ${sessionID}` },
  396. }
  397. const missing = yield* capture(() => sdk.session.get({ sessionID }))
  398. const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
  399. // Result-tuple path: error body is preserved as-is so existing
  400. // consumers reading `result.error.name` / `JSON.stringify(error)`
  401. // keep working byte-for-byte.
  402. expect(missing.error).toEqual(expected)
  403. // throwOnError path: SDK wraps the body in a real Error with the
  404. // server's message, with the original parsed body preserved under
  405. // `.cause.body`.
  406. expect(thrown).toBeInstanceOf(Error)
  407. expect((thrown as Error).message).toBe(expected.data.message)
  408. expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
  409. return {
  410. status: missing.status,
  411. error: missing.error,
  412. thrown,
  413. }
  414. }),
  415. ),
  416. )
  417. serverPathParity("formats missing session validation errors for -s", (serverPath) =>
  418. withStandardProject(serverPath, ({ directory }) =>
  419. Effect.gen(function* () {
  420. const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
  421. const thrown = yield* captureThrown(() =>
  422. validateSession({
  423. url: "http://localhost",
  424. directory,
  425. sessionID,
  426. fetch: serverFetch(serverPath),
  427. }),
  428. )
  429. expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
  430. return errorMessage(thrown)
  431. }),
  432. ),
  433. )
  434. httpapiInstance(
  435. "uses generated SDK basic auth behavior",
  436. { serverPath: "raw", setup: writeStandardFiles },
  437. ({ directory }) =>
  438. Effect.gen(function* () {
  439. const missing = yield* capture(() =>
  440. client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
  441. )
  442. const bad = yield* capture(() =>
  443. client("raw", directory, {
  444. password: "secret",
  445. headers: { authorization: authorization("opencode", "wrong") },
  446. }).file.read({ path: "hello.txt" }),
  447. )
  448. const good = yield* capture(() =>
  449. client("raw", directory, {
  450. password: "secret",
  451. headers: { authorization: authorization("opencode", "secret") },
  452. }).file.read({ path: "hello.txt" }),
  453. )
  454. return {
  455. statuses: statuses({ missing, bad, good }),
  456. content: record(good.data).content,
  457. }
  458. }),
  459. )
  460. serverPathParity("matches generated SDK instance read routes", (serverPath) =>
  461. withStandardProject(serverPath, ({ sdk, directory }) =>
  462. Effect.gen(function* () {
  463. const project = yield* capture(() => sdk.project.current())
  464. const projects = yield* capture(() => sdk.project.list())
  465. const paths = yield* capture(() => sdk.path.get())
  466. const config = yield* capture(() => sdk.config.get())
  467. const providers = yield* capture(() => sdk.config.providers())
  468. const file = yield* capture(() => sdk.file.read({ path: "hello.txt" }))
  469. const files = yield* capture(() => sdk.file.list({ path: "." }))
  470. const fileStatus = yield* capture(() => sdk.file.status())
  471. const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 }))
  472. const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" }))
  473. const agents = yield* capture(() => sdk.app.agents())
  474. const skills = yield* capture(() => sdk.app.skills())
  475. const tools = yield* capture(() => sdk.tool.ids())
  476. const vcs = yield* capture(() => sdk.vcs.get())
  477. const formatter = yield* capture(() => sdk.formatter.status())
  478. const lsp = yield* capture(() => sdk.lsp.status())
  479. return {
  480. statuses: statuses({
  481. project,
  482. projects,
  483. paths,
  484. config,
  485. providers,
  486. file,
  487. files,
  488. fileStatus,
  489. findFiles,
  490. findText,
  491. agents,
  492. skills,
  493. tools,
  494. vcs,
  495. formatter,
  496. lsp,
  497. }),
  498. project: { worktreeSelected: record(project.data).worktree === directory },
  499. paths: { directorySelected: record(paths.data).directory === directory },
  500. file: record(file.data).content,
  501. hasProject: array(projects.data).length > 0,
  502. foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
  503. foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
  504. listedFile: JSON.stringify(files.data).includes("hello.txt"),
  505. }
  506. }),
  507. ),
  508. )
  509. serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
  510. withStandardProject(serverPath, ({ sdk }) =>
  511. Effect.gen(function* () {
  512. const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
  513. const parentID = String(record(parent.data).id)
  514. const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
  515. const childID = String(record(child.data).id)
  516. const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
  517. const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
  518. const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
  519. const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
  520. const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
  521. const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
  522. const status = yield* capture(() => sdk.session.status())
  523. const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
  524. const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
  525. const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
  526. const invalidCursor = yield* capture(() =>
  527. sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }),
  528. )
  529. const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
  530. const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
  531. return {
  532. statuses: statuses({
  533. parent,
  534. child,
  535. get,
  536. update,
  537. roots,
  538. all,
  539. children,
  540. todo,
  541. status,
  542. messages,
  543. missingGet,
  544. missingMessages,
  545. invalidCursor,
  546. deleted,
  547. getDeleted,
  548. }),
  549. getTitle: record(get.data).title,
  550. updatedTitle: record(update.data).title,
  551. rootTitles: sessionTitles(roots.data),
  552. allTitles: sessionTitles(all.data),
  553. childCount: array(children.data).length,
  554. todoCount: array(todo.data).length,
  555. messageCount: array(messages.data).length,
  556. }
  557. }),
  558. ),
  559. )
  560. serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
  561. withStandardProject(serverPath, ({ sdk, directory }) =>
  562. Effect.gen(function* () {
  563. const session = yield* capture(() => sdk.session.create({ title: "messages" }))
  564. const sessionID = String(record(session.data).id)
  565. const seeded = yield* seedMessage(directory, sessionID)
  566. const list = yield* capture(() => sdk.session.messages({ sessionID }))
  567. const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
  568. const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
  569. const partUpdate = yield* capture(() =>
  570. sdk.part.update({
  571. sessionID,
  572. messageID: seeded.message.id,
  573. partID: seeded.part.id,
  574. part: { ...seeded.part, text: "updated message" } as NonNullable<
  575. Parameters<Sdk["part"]["update"]>[0]["part"]
  576. >,
  577. }),
  578. )
  579. const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
  580. const partDelete = yield* capture(() =>
  581. sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
  582. )
  583. const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
  584. const deleteMessage = yield* capture(() =>
  585. sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }),
  586. )
  587. const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
  588. return {
  589. statuses: statuses({
  590. session,
  591. list,
  592. page,
  593. message,
  594. partUpdate,
  595. updated,
  596. partDelete,
  597. withoutPart,
  598. deleteMessage,
  599. missingMessage,
  600. }),
  601. listCount: array(list.data).length,
  602. pageCount: array(page.data).length,
  603. initialText: firstPartText(message.data),
  604. updatedText: firstPartText(updated.data),
  605. partCountAfterDelete: array(record(withoutPart.data).parts).length,
  606. }
  607. }),
  608. ),
  609. )
  610. serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
  611. withStandardProject(serverPath, ({ sdk }) =>
  612. Effect.gen(function* () {
  613. const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
  614. const sessionID = String(record(session.data).id)
  615. const prompt = yield* capture(() =>
  616. sdk.session.prompt({
  617. sessionID,
  618. agent: "build",
  619. noReply: true,
  620. parts: [{ type: "text", text: "hello" }],
  621. }),
  622. )
  623. const asyncPrompt = yield* capture(() =>
  624. sdk.session.promptAsync({
  625. sessionID,
  626. agent: "build",
  627. noReply: true,
  628. parts: [{ type: "text", text: "async hello" }],
  629. }),
  630. )
  631. const messages = yield* capture(() => sdk.session.messages({ sessionID }))
  632. return {
  633. statuses: statuses({ session, prompt, asyncPrompt, messages }),
  634. promptRole: record(record(prompt.data).info).role,
  635. messageCount: array(messages.data).length,
  636. messageTexts: array(messages.data)
  637. .flatMap((item) => array(record(item).parts))
  638. .map((part) => record(part).text)
  639. .filter((text): text is string => typeof text === "string")
  640. .sort(),
  641. }
  642. }),
  643. ),
  644. )
  645. serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
  646. withFakeLlm(serverPath, ({ sdk, llm }) =>
  647. Effect.gen(function* () {
  648. yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
  649. const session = yield* capture(() =>
  650. sdk.session.create({
  651. title: "llm prompt",
  652. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  653. }),
  654. )
  655. const sessionID = String(record(session.data).id)
  656. const prompt = yield* capture(() =>
  657. sdk.session.prompt({
  658. sessionID,
  659. agent: "build",
  660. model: { providerID: "test", modelID: "test-model" },
  661. parts: [{ type: "text", text: "hello llm" }],
  662. }),
  663. )
  664. const messages = yield* capture(() => sdk.session.messages({ sessionID }))
  665. const inputs = yield* llm.inputs
  666. return {
  667. statuses: statuses({ session, prompt, messages }),
  668. calls: inputs.length,
  669. requestedModel: inputs[0]?.model,
  670. responseText: JSON.stringify(prompt.data).includes("fake world"),
  671. persistedText: JSON.stringify(messages.data).includes("fake world"),
  672. userText: JSON.stringify(messages.data).includes("hello llm"),
  673. }
  674. }),
  675. ),
  676. )
  677. httpapi(
  678. "includes project skills in REST API async prompt context",
  679. withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
  680. Effect.gen(function* () {
  681. yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } })
  682. const session = yield* capture(() =>
  683. sdk.session.create({
  684. title: "project skill prompt",
  685. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  686. }),
  687. )
  688. const sessionID = String(record(session.data).id)
  689. const prompt = yield* capture(() =>
  690. sdk.session.promptAsync({
  691. sessionID,
  692. agent: "build",
  693. model: { providerID: "test", modelID: "test-model" },
  694. parts: [{ type: "text", text: "hello skill context" }],
  695. }),
  696. )
  697. yield* llm.wait(1)
  698. const inputs = yield* llm.inputs
  699. expect(session.status).toBe(200)
  700. expect(prompt.status).toBe(204)
  701. expect(JSON.stringify(inputs[0])).toContain("project-rest-skill")
  702. }),
  703. ),
  704. )
  705. serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) =>
  706. withStandardProject(serverPath, ({ sdk }) =>
  707. Effect.gen(function* () {
  708. const session = yield* capture(() => sdk.session.create({ title: "tui" }))
  709. const sessionID = String(record(session.data).id)
  710. const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" }))
  711. const openHelp = yield* capture(() => sdk.tui.openHelp())
  712. const openSessions = yield* capture(() => sdk.tui.openSessions())
  713. const openThemes = yield* capture(() => sdk.tui.openThemes())
  714. const openModels = yield* capture(() => sdk.tui.openModels())
  715. const submitPrompt = yield* capture(() => sdk.tui.submitPrompt())
  716. const clearPrompt = yield* capture(() => sdk.tui.clearPrompt())
  717. const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" }))
  718. const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
  719. const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID }))
  720. const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" }))
  721. const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
  722. return {
  723. statuses: statuses({
  724. session,
  725. appendPrompt,
  726. openHelp,
  727. openSessions,
  728. openThemes,
  729. openModels,
  730. submitPrompt,
  731. clearPrompt,
  732. executeCommand,
  733. showToast,
  734. selectSession,
  735. missingSession,
  736. invalidSession,
  737. }),
  738. data: {
  739. appendPrompt: appendPrompt.data,
  740. openHelp: openHelp.data,
  741. openSessions: openSessions.data,
  742. openThemes: openThemes.data,
  743. openModels: openModels.data,
  744. submitPrompt: submitPrompt.data,
  745. clearPrompt: clearPrompt.data,
  746. executeCommand: executeCommand.data,
  747. showToast: showToast.data,
  748. selectSession: selectSession.data,
  749. },
  750. }
  751. }),
  752. ),
  753. )
  754. serverPathParity("matches generated SDK project git initialization", (serverPath) =>
  755. withProject(serverPath, { git: false }, ({ sdk, directory }) =>
  756. Effect.gen(function* () {
  757. const before = yield* capture(() => sdk.project.current())
  758. const init = yield* capture(() => sdk.project.initGit())
  759. const after = yield* capture(() => sdk.project.current())
  760. return {
  761. statuses: statuses({ before, init, after }),
  762. before: {
  763. vcs: record(before.data).vcs ?? null,
  764. worktree: record(before.data).worktree,
  765. },
  766. init: {
  767. vcs: record(init.data).vcs,
  768. worktreeSelected: record(init.data).worktree === directory,
  769. },
  770. after: {
  771. vcs: record(after.data).vcs,
  772. worktreeSelected: record(after.data).worktree === directory,
  773. },
  774. }
  775. }),
  776. ),
  777. )
  778. })