entry.body.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import { describe, expect, test } from "bun:test"
  2. import type { ToolPart } from "@opencode-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: [
  256. '<task id="child-1" state="completed">',
  257. "<task_result>",
  258. "",
  259. "</task_result>",
  260. "</task>",
  261. ].join("\n"),
  262. metadata: {
  263. sessionId: "child-1",
  264. },
  265. time: { start: 1, end: 2 },
  266. },
  267. }),
  268. ),
  269. ).toEqual({
  270. kind: "task",
  271. title: "# Explore Task",
  272. rows: ["Inspect reducer"],
  273. tail: "",
  274. })
  275. })
  276. test("streams tool progress text and treats completed progress as done", () => {
  277. const body = entryBody(
  278. commit({
  279. kind: "tool",
  280. text: "partial output",
  281. phase: "progress",
  282. source: "tool",
  283. tool: "bash",
  284. partID: "tool-2",
  285. }),
  286. )
  287. expect(body).toEqual({
  288. type: "text",
  289. content: "partial output",
  290. })
  291. expect(
  292. entryCanStream(
  293. commit({
  294. kind: "tool",
  295. text: "partial output",
  296. phase: "progress",
  297. source: "tool",
  298. tool: "bash",
  299. }),
  300. body,
  301. ),
  302. ).toBe(true)
  303. expect(
  304. entryDone(
  305. commit({
  306. kind: "tool",
  307. text: "output",
  308. phase: "progress",
  309. source: "tool",
  310. tool: "bash",
  311. toolState: "completed",
  312. }),
  313. ),
  314. ).toBe(true)
  315. })
  316. test("formats completed bash output with a blank line after the command and no trailing blank row", () => {
  317. expect(
  318. entryBody(
  319. toolCommit({
  320. tool: "bash",
  321. phase: "progress",
  322. toolState: "completed",
  323. text: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join("\n"),
  324. state: {
  325. status: "completed",
  326. input: {
  327. command: "git status",
  328. workdir: "/tmp/demo",
  329. },
  330. output: ["/tmp/demo", "git status", "On branch demo", "nothing to commit, working tree clean", ""].join(
  331. "\n",
  332. ),
  333. title: "git status",
  334. metadata: {
  335. exitCode: 0,
  336. },
  337. time: { start: 1, end: 2 },
  338. },
  339. }),
  340. ),
  341. ).toEqual({
  342. type: "text",
  343. content: "\nOn branch demo\nnothing to commit, working tree clean",
  344. })
  345. })
  346. test("renders command-only bash starts without the shell header", () => {
  347. expect(
  348. entryBody(
  349. toolCommit({
  350. tool: "bash",
  351. phase: "start",
  352. toolState: "running",
  353. text: "running shell",
  354. state: {
  355. status: "running",
  356. input: {
  357. command: "ls",
  358. },
  359. time: { start: 1 },
  360. },
  361. }),
  362. ),
  363. ).toEqual({
  364. type: "text",
  365. content: "$ ls",
  366. })
  367. })
  368. test("renders direct shell commits without a synthetic shell header", () => {
  369. expect(
  370. entryBody(
  371. commit({
  372. kind: "tool",
  373. text: "running shell",
  374. phase: "start",
  375. source: "tool",
  376. tool: "bash",
  377. partID: "shell:call-1",
  378. toolState: "running",
  379. shell: {
  380. callID: "call-1",
  381. command: "pwd",
  382. },
  383. }),
  384. ),
  385. ).toEqual({
  386. type: "text",
  387. content: "$ pwd",
  388. })
  389. expect(
  390. entryBody(
  391. commit({
  392. kind: "tool",
  393. text: "/tmp/demo\n",
  394. phase: "progress",
  395. source: "tool",
  396. tool: "bash",
  397. partID: "shell:call-1",
  398. toolState: "completed",
  399. shell: {
  400. callID: "call-1",
  401. command: "pwd",
  402. },
  403. }),
  404. ),
  405. ).toEqual({
  406. type: "text",
  407. content: "\n/tmp/demo",
  408. })
  409. })
  410. test("falls back to patch summary when apply_patch has no visible diff items", () => {
  411. expect(
  412. entryBody(
  413. toolCommit({
  414. tool: "apply_patch",
  415. state: {
  416. status: "completed",
  417. input: {
  418. patchText: "*** Begin Patch\n*** End Patch",
  419. },
  420. output: "",
  421. title: "",
  422. metadata: {
  423. files: [
  424. {
  425. type: "update",
  426. filePath: "src/a.ts",
  427. relativePath: "src/a.ts",
  428. diff: "@@ -1 +1 @@\n-old\n+new\n",
  429. },
  430. ],
  431. },
  432. time: { start: 1, end: 2 },
  433. },
  434. }),
  435. ),
  436. ).toEqual({
  437. type: "text",
  438. content: "~ Patched src/a.ts",
  439. })
  440. })
  441. test("suppresses redundant patched rows when apply_patch also created a file", () => {
  442. expect(
  443. entryBody(
  444. toolCommit({
  445. tool: "apply_patch",
  446. state: {
  447. status: "completed",
  448. input: {
  449. patchText: "*** Begin Patch\n*** End Patch",
  450. },
  451. output: "",
  452. title: "",
  453. metadata: {
  454. files: [
  455. {
  456. type: "update",
  457. filePath: "src/a.ts",
  458. relativePath: "src/a.ts",
  459. diff: "@@ -1 +1 @@\n-old\n+new\n",
  460. },
  461. {
  462. type: "add",
  463. filePath: "README-demo.md",
  464. relativePath: "README-demo.md",
  465. },
  466. ],
  467. },
  468. time: { start: 1, end: 2 },
  469. },
  470. }),
  471. ),
  472. ).toEqual({
  473. type: "text",
  474. content: "+ Created README-demo.md",
  475. })
  476. })
  477. test("renders glob failures as the raw error under the existing header", () => {
  478. expect(
  479. entryBody(
  480. toolCommit({
  481. tool: "glob",
  482. phase: "final",
  483. toolState: "error",
  484. state: {
  485. status: "error",
  486. input: {
  487. pattern: "**/*tool*",
  488. path: "/tmp/demo/run",
  489. },
  490. error: "No such file or directory: '/tmp/demo/run'",
  491. metadata: {},
  492. time: { start: 1, end: 2 },
  493. },
  494. }),
  495. ),
  496. ).toEqual({
  497. type: "text",
  498. content: "No such file or directory: '/tmp/demo/run'",
  499. })
  500. })
  501. test("renders interrupted assistant finals as text", () => {
  502. expect(
  503. entryBody(
  504. commit({
  505. kind: "assistant",
  506. text: "",
  507. phase: "final",
  508. source: "assistant",
  509. interrupted: true,
  510. partID: "part-1",
  511. }),
  512. ),
  513. ).toEqual({
  514. type: "text",
  515. content: "assistant interrupted",
  516. })
  517. })
  518. })