httpapi-sdk.test.ts 25 KB

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