session-data.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. import { describe, expect, test } from "bun:test"
  2. import type { Event } from "@kirincode-ai/sdk/v2"
  3. import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
  4. import type { StreamCommit } from "@/cli/cmd/run/types"
  5. function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
  6. return reduceSessionData({
  7. data,
  8. event: event as Event,
  9. sessionID: "session-1",
  10. thinking,
  11. limits: {},
  12. })
  13. }
  14. function assistant(id: string, extra: Record<string, unknown> = {}) {
  15. return {
  16. type: "message.updated",
  17. properties: {
  18. sessionID: "session-1",
  19. info: {
  20. id,
  21. role: "assistant",
  22. providerID: "openai",
  23. modelID: "gpt-5",
  24. tokens: {
  25. input: 1,
  26. output: 1,
  27. reasoning: 0,
  28. cache: { read: 0, write: 0 },
  29. },
  30. ...extra,
  31. },
  32. },
  33. }
  34. }
  35. function user(id: string) {
  36. return {
  37. type: "message.updated",
  38. properties: {
  39. sessionID: "session-1",
  40. info: {
  41. id,
  42. role: "user",
  43. },
  44. },
  45. }
  46. }
  47. function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
  48. return {
  49. type: "message.part.updated",
  50. properties: {
  51. part: {
  52. id: input.id,
  53. messageID: input.messageID,
  54. sessionID: "session-1",
  55. type: "text",
  56. text: input.text,
  57. ...(input.time ? { time: input.time } : {}),
  58. },
  59. },
  60. }
  61. }
  62. function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
  63. return {
  64. type: "message.part.updated",
  65. properties: {
  66. part: {
  67. id: input.id,
  68. messageID: input.messageID,
  69. sessionID: "session-1",
  70. type: "reasoning",
  71. text: input.text,
  72. ...(input.time ? { time: input.time } : {}),
  73. },
  74. },
  75. }
  76. }
  77. function delta(messageID: string, partID: string, value: string) {
  78. return {
  79. type: "message.part.delta",
  80. properties: {
  81. sessionID: "session-1",
  82. messageID,
  83. partID,
  84. field: "text",
  85. delta: value,
  86. },
  87. }
  88. }
  89. function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
  90. return {
  91. type: "message.part.updated",
  92. properties: {
  93. part: {
  94. id: input.id,
  95. messageID: input.messageID,
  96. sessionID: "session-1",
  97. type: "tool",
  98. tool: input.tool,
  99. ...(input.callID ? { callID: input.callID } : {}),
  100. state: input.state,
  101. },
  102. },
  103. }
  104. }
  105. describe("run session data", () => {
  106. test("buffers delayed assistant text until the role is known", () => {
  107. let data = createSessionData()
  108. data = reduce(data, delta("msg-1", "txt-1", "hello")).data
  109. data = reduce(data, assistant("msg-1")).data
  110. const out = reduce(
  111. data,
  112. text({
  113. id: "txt-1",
  114. messageID: "msg-1",
  115. text: "",
  116. time: { end: 1 },
  117. }),
  118. )
  119. expect(out.commits).toEqual([
  120. expect.objectContaining({
  121. kind: "assistant",
  122. text: "hello",
  123. partID: "txt-1",
  124. }),
  125. ])
  126. })
  127. test("keeps leading whitespace buffered until real assistant content arrives", () => {
  128. let data = createSessionData()
  129. data = reduce(data, assistant("msg-1")).data
  130. data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
  131. let out = reduce(data, delta("msg-1", "txt-1", " "))
  132. expect(out.commits).toEqual([])
  133. out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
  134. expect(out.commits).toEqual([
  135. expect.objectContaining({
  136. kind: "assistant",
  137. text: " Found",
  138. }),
  139. ])
  140. })
  141. test("drops delayed text once the message resolves to a user role", () => {
  142. let data = createSessionData()
  143. data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
  144. const out = reduce(data, user("msg-user-1"))
  145. expect(out.commits).toEqual([])
  146. expect(out.data.ids.has("txt-user-1")).toBe(true)
  147. })
  148. test("suppresses reasoning commits when thinking is disabled", () => {
  149. const out = reduce(
  150. createSessionData(),
  151. reasoning({
  152. id: "reason-1",
  153. messageID: "msg-1",
  154. text: "hidden",
  155. time: { end: 1 },
  156. }),
  157. false,
  158. )
  159. expect(out.commits).toEqual([])
  160. expect(out.data.ids.has("reason-1")).toBe(true)
  161. })
  162. test("keeps permission precedence over queued questions", () => {
  163. let data = createSessionData()
  164. data = reduce(data, {
  165. type: "permission.asked",
  166. properties: {
  167. id: "perm-1",
  168. sessionID: "session-1",
  169. permission: "read",
  170. patterns: ["/tmp/file.txt"],
  171. metadata: {},
  172. always: [],
  173. },
  174. }).data
  175. const ask = reduce(data, {
  176. type: "question.asked",
  177. properties: {
  178. id: "question-1",
  179. sessionID: "session-1",
  180. questions: [
  181. {
  182. question: "Mode?",
  183. header: "Mode",
  184. options: [{ label: "chunked", description: "Incremental output" }],
  185. multiple: false,
  186. },
  187. ],
  188. },
  189. })
  190. expect(ask.footer).toEqual({
  191. patch: { status: "awaiting permission" },
  192. view: {
  193. type: "permission",
  194. request: expect.objectContaining({ id: "perm-1" }),
  195. },
  196. })
  197. expect(
  198. reduce(ask.data, {
  199. type: "permission.replied",
  200. properties: {
  201. sessionID: "session-1",
  202. requestID: "perm-1",
  203. reply: "reject",
  204. },
  205. }).footer,
  206. ).toEqual({
  207. patch: { status: "awaiting answer" },
  208. view: {
  209. type: "question",
  210. request: expect.objectContaining({ id: "question-1" }),
  211. },
  212. })
  213. })
  214. test("refreshes the active permission view when tool input arrives later", () => {
  215. const data = reduce(createSessionData(), {
  216. type: "permission.asked",
  217. properties: {
  218. id: "perm-1",
  219. sessionID: "session-1",
  220. permission: "bash",
  221. patterns: ["src/**/*.ts"],
  222. metadata: {},
  223. always: [],
  224. tool: {
  225. messageID: "msg-1",
  226. callID: "call-1",
  227. },
  228. },
  229. }).data
  230. const out = reduce(
  231. data,
  232. tool({
  233. id: "tool-1",
  234. messageID: "msg-1",
  235. callID: "call-1",
  236. tool: "bash",
  237. state: {
  238. status: "running",
  239. input: {
  240. command: "git status --short",
  241. },
  242. },
  243. }),
  244. )
  245. expect(out.footer).toEqual({
  246. view: {
  247. type: "permission",
  248. request: expect.objectContaining({
  249. id: "perm-1",
  250. metadata: expect.objectContaining({
  251. input: {
  252. command: "git status --short",
  253. },
  254. }),
  255. }),
  256. },
  257. })
  258. })
  259. test("strips bash echo only from the first assistant flush", () => {
  260. let data = createSessionData()
  261. data = reduce(data, assistant("msg-1")).data
  262. data = reduce(
  263. data,
  264. tool({
  265. id: "tool-1",
  266. messageID: "msg-1",
  267. tool: "bash",
  268. state: {
  269. status: "completed",
  270. input: {
  271. command: "printf hi",
  272. },
  273. output: "echoed\n",
  274. time: { start: 1, end: 2 },
  275. },
  276. }),
  277. ).data
  278. const first = reduce(
  279. data,
  280. text({
  281. id: "txt-1",
  282. messageID: "msg-1",
  283. text: "echoed\nanswer",
  284. }),
  285. )
  286. expect(first.commits).toEqual([
  287. expect.objectContaining({
  288. kind: "assistant",
  289. text: "answer",
  290. }),
  291. ])
  292. expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
  293. expect.objectContaining({
  294. kind: "assistant",
  295. text: "\nechoed\nagain",
  296. }),
  297. ])
  298. })
  299. test("renders direct shell mode from first-class shell events", () => {
  300. let data = createSessionData()
  301. const started = reduce(data, {
  302. type: "session.next.shell.started",
  303. properties: {
  304. sessionID: "session-1",
  305. timestamp: 1,
  306. callID: "call-1",
  307. command: "pwd",
  308. },
  309. })
  310. expect(started.commits).toEqual([
  311. expect.objectContaining({
  312. kind: "tool",
  313. phase: "start",
  314. partID: "shell:call-1",
  315. tool: "bash",
  316. shell: {
  317. callID: "call-1",
  318. command: "pwd",
  319. },
  320. }),
  321. ])
  322. data = started.data
  323. const ended = reduce(data, {
  324. type: "session.next.shell.ended",
  325. properties: {
  326. sessionID: "session-1",
  327. timestamp: 2,
  328. callID: "call-1",
  329. output: "/tmp/demo\n",
  330. },
  331. })
  332. expect(ended.commits).toEqual([
  333. expect.objectContaining({
  334. kind: "tool",
  335. phase: "progress",
  336. partID: "shell:call-1",
  337. tool: "bash",
  338. text: "/tmp/demo\n",
  339. toolState: "completed",
  340. shell: {
  341. callID: "call-1",
  342. command: "pwd",
  343. },
  344. }),
  345. ])
  346. })
  347. test("suppresses legacy bash part updates once shell events claim the call", () => {
  348. let data = reduce(createSessionData(), {
  349. type: "session.next.shell.started",
  350. properties: {
  351. sessionID: "session-1",
  352. timestamp: 1,
  353. callID: "call-1",
  354. command: "pwd",
  355. },
  356. }).data
  357. expect(
  358. reduce(
  359. data,
  360. tool({
  361. id: "tool-1",
  362. messageID: "msg-1",
  363. callID: "call-1",
  364. tool: "bash",
  365. state: {
  366. status: "running",
  367. input: {
  368. command: "pwd",
  369. },
  370. time: { start: 1 },
  371. },
  372. }),
  373. ).commits,
  374. ).toEqual([])
  375. data = reduce(data, {
  376. type: "session.next.shell.ended",
  377. properties: {
  378. sessionID: "session-1",
  379. timestamp: 2,
  380. callID: "call-1",
  381. output: "/tmp/demo\n",
  382. },
  383. }).data
  384. expect(
  385. reduce(
  386. data,
  387. tool({
  388. id: "tool-1",
  389. messageID: "msg-1",
  390. callID: "call-1",
  391. tool: "bash",
  392. state: {
  393. status: "completed",
  394. input: {
  395. command: "pwd",
  396. },
  397. output: "/tmp/demo\n",
  398. title: "",
  399. metadata: {
  400. output: "/tmp/demo\n",
  401. },
  402. time: { start: 1, end: 2 },
  403. },
  404. }),
  405. ).commits,
  406. ).toEqual([])
  407. })
  408. test("suppresses shell events when the legacy bash part claimed the call first", () => {
  409. let data = reduce(
  410. createSessionData(),
  411. tool({
  412. id: "tool-1",
  413. messageID: "msg-1",
  414. callID: "call-1",
  415. tool: "bash",
  416. state: {
  417. status: "running",
  418. input: {
  419. command: "pwd",
  420. },
  421. time: { start: 1 },
  422. },
  423. }),
  424. ).data
  425. expect(
  426. reduce(data, {
  427. type: "session.next.shell.started",
  428. properties: {
  429. sessionID: "session-1",
  430. timestamp: 1,
  431. callID: "call-1",
  432. command: "pwd",
  433. },
  434. }).commits,
  435. ).toEqual([])
  436. data = reduce(
  437. data,
  438. tool({
  439. id: "tool-1",
  440. messageID: "msg-1",
  441. callID: "call-1",
  442. tool: "bash",
  443. state: {
  444. status: "completed",
  445. input: {
  446. command: "pwd",
  447. },
  448. output: "/tmp/demo\n",
  449. title: "",
  450. metadata: {
  451. output: "/tmp/demo\n",
  452. },
  453. time: { start: 1, end: 2 },
  454. },
  455. }),
  456. ).data
  457. expect(
  458. reduce(data, {
  459. type: "session.next.shell.ended",
  460. properties: {
  461. sessionID: "session-1",
  462. timestamp: 2,
  463. callID: "call-1",
  464. output: "/tmp/demo\n",
  465. },
  466. }).commits,
  467. ).toEqual([])
  468. })
  469. test("synthesizes a glob start before an error when the running update is missed", () => {
  470. expect(
  471. reduce(
  472. createSessionData(),
  473. tool({
  474. id: "tool-1",
  475. messageID: "msg-1",
  476. tool: "glob",
  477. state: {
  478. status: "error",
  479. input: {
  480. pattern: "**/*tool*",
  481. path: "/tmp/demo/run",
  482. },
  483. error: "No such file or directory: '/tmp/demo/run'",
  484. },
  485. }),
  486. ).commits,
  487. ).toEqual([
  488. expect.objectContaining({
  489. kind: "tool",
  490. tool: "glob",
  491. phase: "start",
  492. partID: "tool-1",
  493. text: "running glob",
  494. toolState: "running",
  495. }),
  496. expect.objectContaining({
  497. kind: "tool",
  498. tool: "glob",
  499. phase: "final",
  500. partID: "tool-1",
  501. text: "No such file or directory: '/tmp/demo/run'",
  502. toolState: "error",
  503. toolError: "No such file or directory: '/tmp/demo/run'",
  504. }),
  505. ])
  506. })
  507. test("flushInterrupted emits one interrupted final per live part", () => {
  508. const data = reduce(
  509. createSessionData(),
  510. text({
  511. id: "txt-1",
  512. messageID: "msg-1",
  513. text: "unfinished",
  514. }),
  515. ).data
  516. const first: StreamCommit[] = []
  517. flushInterrupted(data, first)
  518. expect(first).toEqual([
  519. expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
  520. expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
  521. ])
  522. const next: StreamCommit[] = []
  523. flushInterrupted(data, next)
  524. expect(next).toEqual([])
  525. })
  526. test("surfaces session errors as error commits", () => {
  527. const out = reduce(createSessionData(), {
  528. type: "session.error",
  529. properties: {
  530. sessionID: "session-1",
  531. error: {
  532. name: "UnknownError",
  533. data: {
  534. message: "permission denied",
  535. },
  536. },
  537. },
  538. })
  539. expect(out.commits).toEqual([
  540. expect.objectContaining({
  541. kind: "error",
  542. text: "permission denied",
  543. }),
  544. ])
  545. })
  546. })