httpapi-sdk.test.ts 29 KB

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