httpapi-sdk.test.ts 34 KB

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