entry.body.test.ts 15 KB

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