httpapi-sdk.test.ts 25 KB

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