data.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. /** @jsxImportSource @opentui/solid */
  2. import { expect, test } from "bun:test"
  3. import { testRender } from "@opentui/solid"
  4. import type { Event, GlobalEvent } from "@kirincode-ai/sdk/v2"
  5. import { onMount } from "solid-js"
  6. import { ProjectProvider } from "../../../src/context/project"
  7. import { SDKProvider } from "../../../src/context/sdk"
  8. import { DataProvider, useData } from "../../../src/context/data"
  9. import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
  10. import { TestTuiContexts } from "../../fixture/tui-environment"
  11. async function wait(fn: () => boolean, timeout = 2000) {
  12. const start = Date.now()
  13. while (!fn()) {
  14. if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
  15. await Bun.sleep(10)
  16. }
  17. }
  18. function global(payload: Event): GlobalEvent {
  19. return { directory, project: "proj_test", payload }
  20. }
  21. function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
  22. events.emit(global(payload))
  23. }
  24. test("refreshes resources into reactive getters", async () => {
  25. const events = createEventSource()
  26. const location = {
  27. directory,
  28. project: { id: "proj_test", directory },
  29. }
  30. const calls = createFetch((url) => {
  31. if (url.pathname === "/api/session/ses_test")
  32. return json({
  33. data: {
  34. id: "ses_test",
  35. projectID: "proj_test",
  36. cost: 0,
  37. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  38. time: { created: 0, updated: 0 },
  39. title: "Test session",
  40. location: { directory },
  41. },
  42. })
  43. if (url.pathname === "/api/agent")
  44. return json({
  45. location,
  46. data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
  47. })
  48. return undefined
  49. }, events)
  50. let data!: ReturnType<typeof useData>
  51. let ready!: () => void
  52. const mounted = new Promise<void>((resolve) => {
  53. ready = resolve
  54. })
  55. function Probe() {
  56. data = useData()
  57. onMount(ready)
  58. return <box />
  59. }
  60. const app = await testRender(() => (
  61. <TestTuiContexts>
  62. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  63. <ProjectProvider>
  64. <DataProvider>
  65. <Probe />
  66. </DataProvider>
  67. </ProjectProvider>
  68. </SDKProvider>
  69. </TestTuiContexts>
  70. ))
  71. try {
  72. await mounted
  73. expect(data.location.default()).toEqual({ directory })
  74. expect(data.session.get("ses_test")).toBeUndefined()
  75. expect(data.location.agent.list(location)).toBeUndefined()
  76. await data.session.refresh("ses_test")
  77. await data.location.agent.refresh()
  78. expect(data.session.get("ses_test")?.title).toBe("Test session")
  79. expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
  80. expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
  81. } finally {
  82. app.renderer.destroy()
  83. }
  84. })
  85. test("refreshes integrations after integration updates", async () => {
  86. const events = createEventSource()
  87. const requests = { integration: 0, model: 0, provider: 0 }
  88. const calls = createFetch((url) => {
  89. if (url.pathname === "/api/model") {
  90. requests.model++
  91. return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
  92. }
  93. if (url.pathname === "/api/provider") {
  94. requests.provider++
  95. return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
  96. }
  97. if (url.pathname !== "/api/integration") return
  98. requests.integration++
  99. return json({
  100. location: { directory, project: { id: "proj_test", directory } },
  101. data:
  102. requests.integration === 1
  103. ? []
  104. : [
  105. {
  106. id: "openai",
  107. name: "OpenAI",
  108. methods: [{ type: "key" }],
  109. },
  110. ],
  111. })
  112. }, events)
  113. let data!: ReturnType<typeof useData>
  114. let ready!: () => void
  115. const mounted = new Promise<void>((resolve) => {
  116. ready = resolve
  117. })
  118. function Probe() {
  119. data = useData()
  120. onMount(ready)
  121. return <box />
  122. }
  123. const app = await testRender(() => (
  124. <TestTuiContexts>
  125. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  126. <ProjectProvider>
  127. <DataProvider>
  128. <Probe />
  129. </DataProvider>
  130. </ProjectProvider>
  131. </SDKProvider>
  132. </TestTuiContexts>
  133. ))
  134. try {
  135. await mounted
  136. await wait(() => data.location.integration.list() !== undefined)
  137. expect(data.location.integration.list()).toEqual([])
  138. const before = { ...requests }
  139. emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
  140. await wait(() => data.location.integration.list()?.length === 1)
  141. await wait(() => requests.model > before.model && requests.provider > before.provider)
  142. expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
  143. } finally {
  144. app.renderer.destroy()
  145. }
  146. })
  147. test("refreshes effective catalog data after catalog updates", async () => {
  148. const events = createEventSource()
  149. const requests = { model: 0, provider: 0 }
  150. const calls = createFetch((url) => {
  151. if (url.pathname === "/api/model") {
  152. requests.model++
  153. return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
  154. }
  155. if (url.pathname === "/api/provider") {
  156. requests.provider++
  157. return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
  158. }
  159. }, events)
  160. const app = await testRender(() => (
  161. <TestTuiContexts>
  162. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  163. <ProjectProvider>
  164. <DataProvider>
  165. <box />
  166. </DataProvider>
  167. </ProjectProvider>
  168. </SDKProvider>
  169. </TestTuiContexts>
  170. ))
  171. try {
  172. await wait(() => requests.model > 0 && requests.provider > 0)
  173. const before = { ...requests }
  174. emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
  175. await wait(() => requests.model > before.model && requests.provider > before.provider)
  176. } finally {
  177. app.renderer.destroy()
  178. }
  179. })
  180. test("refreshes references after updates", async () => {
  181. const events = createEventSource()
  182. let requests = 0
  183. const calls = createFetch((url) => {
  184. if (url.pathname !== "/api/reference") return
  185. requests++
  186. return json({
  187. location: { directory, project: { id: "proj_test", directory } },
  188. data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
  189. })
  190. }, events)
  191. let data!: ReturnType<typeof useData>
  192. let ready!: () => void
  193. const mounted = new Promise<void>((resolve) => {
  194. ready = resolve
  195. })
  196. function Probe() {
  197. data = useData()
  198. onMount(ready)
  199. return <box />
  200. }
  201. const app = await testRender(() => (
  202. <TestTuiContexts>
  203. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  204. <ProjectProvider>
  205. <DataProvider>
  206. <Probe />
  207. </DataProvider>
  208. </ProjectProvider>
  209. </SDKProvider>
  210. </TestTuiContexts>
  211. ))
  212. try {
  213. await mounted
  214. await wait(() => requests === 1)
  215. emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
  216. await wait(() => data.location.reference.list()?.length === 1)
  217. expect(data.location.reference.list()?.[0]?.name).toBe("docs")
  218. } finally {
  219. app.renderer.destroy()
  220. }
  221. })
  222. test("settles pending tools when a live failure arrives", async () => {
  223. const events = createEventSource()
  224. const calls = createFetch(undefined, events)
  225. let sync!: ReturnType<typeof useData>
  226. let ready!: () => void
  227. const mounted = new Promise<void>((resolve) => {
  228. ready = resolve
  229. })
  230. function Probe() {
  231. sync = useData()
  232. onMount(ready)
  233. return <box />
  234. }
  235. const app = await testRender(() => (
  236. <TestTuiContexts>
  237. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  238. <ProjectProvider>
  239. <DataProvider>
  240. <Probe />
  241. </DataProvider>
  242. </ProjectProvider>
  243. </SDKProvider>
  244. </TestTuiContexts>
  245. ))
  246. try {
  247. await mounted
  248. emitEvent(events, {
  249. id: "evt_agent_1",
  250. type: "session.next.agent.switched",
  251. properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
  252. })
  253. emitEvent(events, {
  254. id: "evt_model_1",
  255. type: "session.next.model.switched",
  256. properties: {
  257. sessionID: "session-1",
  258. messageID: "msg_model_1",
  259. timestamp: 0,
  260. model: { id: "model-1", providerID: "provider-1" },
  261. },
  262. })
  263. emitEvent(events, {
  264. id: "evt_step_started_1",
  265. type: "session.next.step.started",
  266. properties: {
  267. sessionID: "session-1",
  268. assistantMessageID: "msg_explicit_assistant_9",
  269. timestamp: 1,
  270. agent: "build",
  271. model: { id: "model-1", providerID: "provider-1" },
  272. },
  273. })
  274. emitEvent(events, {
  275. id: "evt_input_1",
  276. type: "session.next.tool.input.started",
  277. properties: {
  278. sessionID: "session-1",
  279. assistantMessageID: "msg_explicit_assistant_9",
  280. timestamp: 2,
  281. callID: "call-1",
  282. name: "bash",
  283. },
  284. })
  285. emitEvent(events, {
  286. id: "evt_called_1",
  287. type: "session.next.tool.called",
  288. properties: {
  289. sessionID: "session-1",
  290. timestamp: 2,
  291. assistantMessageID: "msg_explicit_assistant_9",
  292. callID: "call-1",
  293. tool: "bash",
  294. input: {},
  295. provider: { executed: false, metadata: { fake: { call: true } } },
  296. },
  297. })
  298. emitEvent(events, {
  299. id: "evt_failed_1",
  300. type: "session.next.tool.failed",
  301. properties: {
  302. sessionID: "session-1",
  303. timestamp: 3,
  304. assistantMessageID: "msg_explicit_assistant_9",
  305. callID: "call-1",
  306. error: { type: "unknown", message: "aborted" },
  307. provider: { executed: false, metadata: { fake: { result: true } } },
  308. },
  309. })
  310. await wait(() => {
  311. const assistant = sync.session.message.list("session-1")?.[0]
  312. return (
  313. assistant?.type === "assistant" &&
  314. assistant.content[0]?.type === "tool" &&
  315. assistant.content[0].state.status === "error"
  316. )
  317. })
  318. const assistant = sync.session.message.list("session-1")?.[0]
  319. expect(assistant?.type).toBe("assistant")
  320. if (assistant?.type !== "assistant") return
  321. expect(assistant.id).toBe("msg_explicit_assistant_9")
  322. const tool = assistant.content[0]
  323. expect(tool?.type).toBe("tool")
  324. if (tool?.type !== "tool") return
  325. expect(tool.state.status).toBe("error")
  326. if (tool.state.status !== "error") return
  327. expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
  328. expect(tool.state.input).toEqual({})
  329. expect(tool.state.structured).toEqual({})
  330. expect(tool.state.content).toEqual([])
  331. expect(tool.provider).toEqual({
  332. executed: false,
  333. metadata: { fake: { call: true } },
  334. resultMetadata: { fake: { result: true } },
  335. })
  336. expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
  337. "assistant",
  338. "model-switched",
  339. "agent-switched",
  340. ])
  341. } finally {
  342. app.renderer.destroy()
  343. }
  344. })
  345. test("renders admitted prompts only after they become model-visible", async () => {
  346. const events = createEventSource()
  347. const calls = createFetch(undefined, events)
  348. let sync!: ReturnType<typeof useData>
  349. let ready!: () => void
  350. const mounted = new Promise<void>((resolve) => {
  351. ready = resolve
  352. })
  353. function Probe() {
  354. sync = useData()
  355. onMount(ready)
  356. return <box />
  357. }
  358. const app = await testRender(() => (
  359. <TestTuiContexts>
  360. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  361. <ProjectProvider>
  362. <DataProvider>
  363. <Probe />
  364. </DataProvider>
  365. </ProjectProvider>
  366. </SDKProvider>
  367. </TestTuiContexts>
  368. ))
  369. try {
  370. await mounted
  371. emitEvent(events, {
  372. id: "evt_admitted_1",
  373. type: "session.next.prompt.admitted",
  374. properties: {
  375. sessionID: "session-1",
  376. messageID: "msg_user_1",
  377. timestamp: 0,
  378. prompt: { text: "hello" },
  379. delivery: "steer",
  380. },
  381. })
  382. expect(sync.session.message.list("session-1") ?? []).toEqual([])
  383. emitEvent(events, {
  384. id: "evt_prompted_1",
  385. type: "session.next.prompted",
  386. properties: {
  387. sessionID: "session-1",
  388. messageID: "msg_user_1",
  389. timestamp: 0,
  390. prompt: { text: "hello" },
  391. delivery: "steer",
  392. },
  393. })
  394. await wait(() => sync.session.message.list("session-1")?.length === 1)
  395. const message = sync.session.message.list("session-1")?.[0]
  396. expect(message?.type).toBe("user")
  397. if (message?.type !== "user") return
  398. expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
  399. } finally {
  400. app.renderer.destroy()
  401. }
  402. })
  403. test("projects live context updates with their message ID", async () => {
  404. const events = createEventSource()
  405. const calls = createFetch(undefined, events)
  406. let sync!: ReturnType<typeof useData>
  407. let ready!: () => void
  408. const mounted = new Promise<void>((resolve) => {
  409. ready = resolve
  410. })
  411. function Probe() {
  412. sync = useData()
  413. onMount(ready)
  414. return <box />
  415. }
  416. const app = await testRender(() => (
  417. <TestTuiContexts>
  418. <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
  419. <ProjectProvider>
  420. <DataProvider>
  421. <Probe />
  422. </DataProvider>
  423. </ProjectProvider>
  424. </SDKProvider>
  425. </TestTuiContexts>
  426. ))
  427. try {
  428. await mounted
  429. emitEvent(events, {
  430. id: "evt_context_1",
  431. type: "session.next.context.updated",
  432. properties: {
  433. sessionID: "session-1",
  434. messageID: "msg_context_1",
  435. timestamp: 1,
  436. text: "Updated context",
  437. },
  438. })
  439. await wait(() => sync.session.message.list("session-1")?.length === 1)
  440. expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
  441. id: "msg_context_1",
  442. type: "system",
  443. text: "Updated context",
  444. })
  445. } finally {
  446. app.renderer.destroy()
  447. }
  448. })