entry.body.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { describe, expect, test } from "bun:test"
  2. import type { ToolPart } from "@kirincode-ai/sdk/v2"
  3. import { entryBody, entryCanStream, entryDone } from "@/cli/cmd/run/entry.body"
  4. import type { StreamCommit, ToolSnapshot } from "@/cli/cmd/run/types"
  5. function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
  6. return input
  7. }
  8. function toolPart(tool: string, state: ToolPart["state"], id = `${tool}-1`, messageID = `msg-${tool}`): ToolPart {
  9. return {
  10. id,
  11. sessionID: "session-1",
  12. messageID,
  13. type: "tool",
  14. callID: `call-${id}`,
  15. tool,
  16. state,
  17. } as ToolPart
  18. }
  19. function toolCommit(input: {
  20. tool: string
  21. state: ToolPart["state"]
  22. phase?: StreamCommit["phase"]
  23. toolState?: StreamCommit["toolState"]
  24. text?: string
  25. id?: string
  26. messageID?: string
  27. }) {
  28. return commit({
  29. kind: "tool",
  30. text: input.text ?? "",
  31. phase: input.phase ?? "final",
  32. source: "tool",
  33. tool: input.tool,
  34. toolState: input.toolState ?? "completed",
  35. part: toolPart(input.tool, input.state, input.id, input.messageID),
  36. })
  37. }
  38. function structured(next: StreamCommit) {
  39. const body = entryBody(next)
  40. expect(body.type).toBe("structured")
  41. if (body.type !== "structured") {
  42. throw new Error("expected structured body")
  43. }
  44. return body.snapshot
  45. }
  46. describe("run entry body", () => {
  47. test("renders assistant, reasoning, and user entries in their display formats", () => {
  48. expect(
  49. entryBody(
  50. commit({
  51. kind: "assistant",
  52. text: "# Title\n\nHello **world**",
  53. phase: "progress",
  54. source: "assistant",
  55. partID: "part-1",
  56. }),
  57. ),
  58. ).toEqual({
  59. type: "markdown",
  60. content: "# Title\n\nHello **world**",
  61. })
  62. const reasoning = entryBody(
  63. commit({
  64. kind: "reasoning",
  65. text: "Thinking: plan next steps",
  66. phase: "progress",
  67. source: "reasoning",
  68. partID: "reason-1",
  69. }),
  70. )
  71. expect(reasoning).toEqual({
  72. type: "code",
  73. filetype: "markdown",
  74. content: "_Thinking:_ plan next steps",
  75. })
  76. expect(
  77. entryCanStream(
  78. commit({
  79. kind: "reasoning",
  80. text: "Thinking: plan next steps",
  81. phase: "progress",
  82. source: "reasoning",
  83. }),
  84. reasoning,
  85. ),
  86. ).toBe(true)
  87. expect(
  88. entryBody(
  89. commit({
  90. kind: "user",
  91. text: "Inspect footer tabs",
  92. phase: "start",
  93. source: "system",
  94. }),
  95. ),
  96. ).toEqual({
  97. type: "text",
  98. content: "› Inspect footer tabs",
  99. })
  100. })
  101. for (const item of [
  102. {
  103. name: "keeps completed write tool finals structured",
  104. commit: toolCommit({
  105. tool: "write",
  106. state: {
  107. status: "completed",
  108. input: {
  109. filePath: "src/a.ts",
  110. content: "const x = 1\n",
  111. },
  112. output: "",
  113. title: "",
  114. metadata: {},
  115. time: { start: 1, end: 2 },
  116. },
  117. }),
  118. snapshot: {
  119. kind: "code",
  120. title: "# Wrote src/a.ts",
  121. content: "const x = 1\n",
  122. file: "src/a.ts",
  123. },
  124. },
  125. {
  126. name: "keeps completed edit tool finals structured",
  127. commit: toolCommit({
  128. tool: "edit",
  129. state: {
  130. status: "completed",
  131. input: {
  132. filePath: "src/a.ts",
  133. },
  134. output: "",
  135. title: "",
  136. metadata: {
  137. diff: "@@ -1 +1 @@\n-old\n+new\n",
  138. },
  139. time: { start: 1, end: 2 },
  140. },
  141. }),
  142. snapshot: {
  143. kind: "diff",
  144. items: [
  145. {
  146. title: "# Edited src/a.ts",
  147. diff: "@@ -1 +1 @@\n-old\n+new\n",
  148. file: "src/a.ts",
  149. },
  150. ],
  151. },
  152. },
  153. {
  154. name: "keeps completed apply_patch tool finals structured",
  155. commit: toolCommit({
  156. tool: "apply_patch",
  157. state: {
  158. status: "completed",
  159. input: {},
  160. output: "",
  161. title: "",
  162. metadata: {
  163. files: [
  164. {
  165. type: "update",
  166. filePath: "src/a.ts",
  167. relativePath: "src/a.ts",
  168. patch: "@@ -1 +1 @@\n-old\n+new\n",
  169. },
  170. ],
  171. },
  172. time: { start: 1, end: 2 },
  173. },
  174. }),
  175. snapshot: {
  176. kind: "diff",
  177. items: [
  178. {
  179. title: "# Patched src/a.ts",
  180. diff: "@@ -1 +1 @@\n-old\n+new\n",
  181. file: "src/a.ts",
  182. deletions: 0,
  183. },
  184. ],
  185. },
  186. },
  187. ] satisfies Array<{ name: string; commit: StreamCommit; snapshot: ToolSnapshot }>) {
  188. test(item.name, () => {
  189. expect(structured(item.commit)).toEqual(item.snapshot)
  190. })
  191. }
  192. test("keeps running task tool state out of scrollback", () => {
  193. expect(
  194. entryBody(
  195. toolCommit({
  196. tool: "task",
  197. phase: "start",
  198. toolState: "running",
  199. text: "running inspect reducer",
  200. state: {
  201. status: "running",
  202. input: {
  203. description: "Inspect reducer",
  204. subagent_type: "explore",
  205. },
  206. time: { start: 1 },
  207. },
  208. }),
  209. ),
  210. ).toEqual({
  211. type: "none",
  212. })
  213. })
  214. test("promotes task results to markdown and falls back to structured task summaries", () => {
  215. expect(
  216. entryBody(
  217. toolCommit({
  218. tool: "task",
  219. state: {
  220. status: "completed",
  221. input: {
  222. description: "Inspect reducer",
  223. subagent_type: "explore",
  224. },
  225. title: "",
  226. output: [
  227. '<task id="child-1" state="completed">',
  228. "<task_result>",
  229. "# Findings\n\n- Footer stays live",
  230. "</task_result>",
  231. "</task>",
  232. ].join("\n"),
  233. metadata: {
  234. sessionId: "child-1",
  235. },
  236. time: { start: 1, end: 2 },
  237. },
  238. }),
  239. ),
  240. ).toEqual({
  241. type: "markdown",
  242. content: "# Findings\n\n- Footer stays live",
  243. })
  244. expect(
  245. structured(
  246. toolCommit({
  247. tool: "task",
  248. state: {
  249. status: "completed",
  250. input: {
  251. description: "Inspect reducer",
  252. subagent_type: "explore",
  253. },
  254. title: "",
  255. output: ['<task id="child-1" state="completed">', "<task_result>", "", "</task_result>", "</task>"].join(
  256. "\n",
  257. ),
  258. metadata: {
  259. sessionId: "child-1",
  260. },
  261. time: { start: 1, end: 2 },
  262. },
  263. }),
  264. ),
  265. ).toEqual({
  266. kind: "task",
  267. title: "# Explore Task",
  268. rows: ["Inspect reducer"],
  269. tail: "",
  270. })
  271. })
  272. test("streams tool progress text and treats completed progress as done", () => {
  273. const body = entryBody(
  274. commit({
  275. kind: "tool",
  276. text: "partial output",
  277. phase: "progress",
  278. source: "tool",
  279. tool: "bash",
  280. partID: "tool-2",
  281. }),
  282. )
  283. expect(body).toEqual({
  284. type: "text",
  285. content: "partial output",
  286. })
  287. expect(
  288. entryCanStream(
  289. commit({
  290. kind: "tool",
  291. text: "partial output",
  292. phase: "progress",
  293. source: "tool",
  294. tool: "bash",
  295. }),
  296. body,
  297. ),
  298. ).toBe(true)
  299. expect(
  300. entryDone(
  301. commit({
  302. kind: "tool",
  303. text: "output",
  304. phase: "progress",
  305. source: "tool",
  306. tool: "bash",
  307. toolState: "completed",
  308. }),
  309. ),
  310. ).toBe(true)
  311. })
  312. test("formats completed bash output with a blank line after the command and no trailing blank row", () => {
  313. expect(
  314. entryBody(
  315. toolCommit({
  316. tool: "bash",
  317. phase: "progress",
  318. toolState: "completed",
  319. text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
  320. state: {
  321. status: "completed",
  322. input: {
  323. command: "git status",
  324. workdir: "/tmp/demo",
  325. },
  326. output: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join(
  327. "\n",
  328. ),
  329. title: "git status",
  330. metadata: {
  331. exitCode: 0,
  332. },
  333. time: { start: 1, end: 2 },
  334. },
  335. }),
  336. ),
  337. ).toEqual({
  338. type: "text",
  339. content: "\nOn branch demo\nnothing to commit, working tree clean",
  340. })
  341. })
  342. test("renders command-only bash starts without the shell header", () => {
  343. expect(
  344. entryBody(
  345. toolCommit({
  346. tool: "bash",
  347. phase: "start",
  348. toolState: "running",
  349. text: "running shell",
  350. state: {
  351. status: "running",
  352. input: {
  353. command: "ls",
  354. },
  355. time: { start: 1 },
  356. },
  357. }),
  358. ),
  359. ).toEqual({
  360. type: "text",
  361. content: "$ ls",
  362. })
  363. })
  364. test("renders direct shell commits without a synthetic shell header", () => {
  365. expect(
  366. entryBody(
  367. commit({
  368. kind: "tool",
  369. text: "running shell",
  370. phase: "start",
  371. source: "tool",
  372. tool: "bash",
  373. partID: "shell:call-1",
  374. toolState: "running",
  375. shell: {
  376. callID: "call-1",
  377. command: "pwd",
  378. },
  379. }),
  380. ),
  381. ).toEqual({
  382. type: "text",
  383. content: "$ pwd",
  384. })
  385. expect(
  386. entryBody(
  387. commit({
  388. kind: "tool",
  389. text: "/tmp/demo\n",
  390. phase: "progress",
  391. source: "tool",
  392. tool: "bash",
  393. partID: "shell:call-1",
  394. toolState: "completed",
  395. shell: {
  396. callID: "call-1",
  397. command: "pwd",
  398. },
  399. }),
  400. ),
  401. ).toEqual({
  402. type: "text",
  403. content: "\n/tmp/demo",
  404. })
  405. })
  406. test("falls back to patch summary when apply_patch has no visible diff items", () => {
  407. expect(
  408. entryBody(
  409. toolCommit({
  410. tool: "apply_patch",
  411. state: {
  412. status: "completed",
  413. input: {
  414. patchText: "*** Begin Patch\n*** End Patch",
  415. },
  416. output: "",
  417. title: "",
  418. metadata: {
  419. files: [
  420. {
  421. type: "update",
  422. filePath: "src/a.ts",
  423. relativePath: "src/a.ts",
  424. diff: "@@ -1 +1 @@\n-old\n+new\n",
  425. },
  426. ],
  427. },
  428. time: { start: 1, end: 2 },
  429. },
  430. }),
  431. ),
  432. ).toEqual({
  433. type: "text",
  434. content: "~ Patched src/a.ts",
  435. })
  436. })
  437. test("suppresses redundant patched rows when apply_patch also created a file", () => {
  438. expect(
  439. entryBody(
  440. toolCommit({
  441. tool: "apply_patch",
  442. state: {
  443. status: "completed",
  444. input: {
  445. patchText: "*** Begin Patch\n*** End Patch",
  446. },
  447. output: "",
  448. title: "",
  449. metadata: {
  450. files: [
  451. {
  452. type: "update",
  453. filePath: "src/a.ts",
  454. relativePath: "src/a.ts",
  455. diff: "@@ -1 +1 @@\n-old\n+new\n",
  456. },
  457. {
  458. type: "add",
  459. filePath: "README-demo.md",
  460. relativePath: "README-demo.md",
  461. },
  462. ],
  463. },
  464. time: { start: 1, end: 2 },
  465. },
  466. }),
  467. ),
  468. ).toEqual({
  469. type: "text",
  470. content: "+ Created README-demo.md",
  471. })
  472. })
  473. test("renders glob failures as the raw error under the existing header", () => {
  474. expect(
  475. entryBody(
  476. toolCommit({
  477. tool: "glob",
  478. phase: "final",
  479. toolState: "error",
  480. state: {
  481. status: "error",
  482. input: {
  483. pattern: "**/*tool*",
  484. path: "/tmp/demo/run",
  485. },
  486. error: "No such file or directory: '/tmp/demo/run'",
  487. metadata: {},
  488. time: { start: 1, end: 2 },
  489. },
  490. }),
  491. ),
  492. ).toEqual({
  493. type: "text",
  494. content: "No such file or directory: '/tmp/demo/run'",
  495. })
  496. })
  497. test("renders interrupted assistant finals as text", () => {
  498. expect(
  499. entryBody(
  500. commit({
  501. kind: "assistant",
  502. text: "",
  503. phase: "final",
  504. source: "assistant",
  505. interrupted: true,
  506. partID: "part-1",
  507. }),
  508. ),
  509. ).toEqual({
  510. type: "text",
  511. content: "assistant interrupted",
  512. })
  513. })
  514. })