transcript.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { describe, expect, test } from "bun:test"
  2. import { formatAssistantHeader, formatMessage, formatPart, formatTranscript } from "../../src/util/transcript"
  3. import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
  4. const providers: Provider[] = [
  5. {
  6. id: "anthropic",
  7. name: "Anthropic",
  8. source: "api",
  9. env: [],
  10. options: {},
  11. models: {
  12. "claude-sonnet-4-20250514": {
  13. id: "claude-sonnet-4-20250514",
  14. providerID: "anthropic",
  15. api: {
  16. id: "claude-sonnet-4-20250514",
  17. url: "https://example.com/claude-sonnet-4-20250514",
  18. npm: "@ai-sdk/anthropic",
  19. },
  20. name: "Claude Sonnet 4",
  21. capabilities: {
  22. temperature: true,
  23. reasoning: true,
  24. attachment: true,
  25. toolcall: true,
  26. input: {
  27. text: true,
  28. audio: false,
  29. image: true,
  30. video: false,
  31. pdf: true,
  32. },
  33. output: {
  34. text: true,
  35. audio: false,
  36. image: false,
  37. video: false,
  38. pdf: false,
  39. },
  40. interleaved: false,
  41. },
  42. cost: {
  43. input: 0,
  44. output: 0,
  45. cache: {
  46. read: 0,
  47. write: 0,
  48. },
  49. },
  50. limit: {
  51. context: 200_000,
  52. output: 8_192,
  53. },
  54. status: "active",
  55. options: {},
  56. headers: {},
  57. release_date: "2025-05-14",
  58. },
  59. },
  60. },
  61. ]
  62. describe("transcript", () => {
  63. describe("formatAssistantHeader", () => {
  64. const baseMsg: AssistantMessage = {
  65. id: "msg_123",
  66. sessionID: "ses_123",
  67. role: "assistant",
  68. agent: "build",
  69. modelID: "claude-sonnet-4-20250514",
  70. providerID: "anthropic",
  71. mode: "",
  72. parentID: "msg_parent",
  73. path: { cwd: "/test", root: "/test" },
  74. cost: 0.001,
  75. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  76. time: { created: 1000000, completed: 1005400 },
  77. }
  78. test("includes metadata when enabled", () => {
  79. const result = formatAssistantHeader(baseMsg, true)
  80. expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n")
  81. })
  82. test("uses model display name when available", () => {
  83. const result = formatAssistantHeader(baseMsg, true, providers)
  84. expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n")
  85. })
  86. test("excludes metadata when disabled", () => {
  87. const result = formatAssistantHeader(baseMsg, false)
  88. expect(result).toBe("## Assistant\n\n")
  89. })
  90. test("handles missing completed time", () => {
  91. const msg = { ...baseMsg, time: { created: 1000000 } }
  92. const result = formatAssistantHeader(msg as AssistantMessage, true)
  93. expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n")
  94. })
  95. test("titlecases agent name", () => {
  96. const msg = { ...baseMsg, agent: "plan" }
  97. const result = formatAssistantHeader(msg, true)
  98. expect(result).toContain("Plan")
  99. })
  100. })
  101. describe("formatPart", () => {
  102. const options = { thinking: true, toolDetails: true, assistantMetadata: true }
  103. test("formats text part", () => {
  104. const part: Part = {
  105. id: "part_1",
  106. sessionID: "ses_123",
  107. messageID: "msg_123",
  108. type: "text",
  109. text: "Hello world",
  110. }
  111. const result = formatPart(part, options)
  112. expect(result).toBe("Hello world\n\n")
  113. })
  114. test("skips synthetic text parts", () => {
  115. const part: Part = {
  116. id: "part_1",
  117. sessionID: "ses_123",
  118. messageID: "msg_123",
  119. type: "text",
  120. text: "Synthetic content",
  121. synthetic: true,
  122. }
  123. const result = formatPart(part, options)
  124. expect(result).toBe("")
  125. })
  126. test("formats reasoning when thinking enabled", () => {
  127. const part: Part = {
  128. id: "part_1",
  129. sessionID: "ses_123",
  130. messageID: "msg_123",
  131. type: "reasoning",
  132. text: "Let me think...",
  133. time: { start: 1000 },
  134. }
  135. const result = formatPart(part, options)
  136. expect(result).toBe("_Thinking:_\n\nLet me think...\n\n")
  137. })
  138. test("skips reasoning when thinking disabled", () => {
  139. const part: Part = {
  140. id: "part_1",
  141. sessionID: "ses_123",
  142. messageID: "msg_123",
  143. type: "reasoning",
  144. text: "Let me think...",
  145. time: { start: 1000 },
  146. }
  147. const result = formatPart(part, { ...options, thinking: false })
  148. expect(result).toBe("")
  149. })
  150. test("formats tool part with details", () => {
  151. const part: Part = {
  152. id: "part_1",
  153. sessionID: "ses_123",
  154. messageID: "msg_123",
  155. type: "tool",
  156. callID: "call_1",
  157. tool: "bash",
  158. state: {
  159. status: "completed",
  160. input: { command: "ls" },
  161. output: "file1.txt\nfile2.txt",
  162. title: "List files",
  163. metadata: {},
  164. time: { start: 1000, end: 1100 },
  165. },
  166. }
  167. const result = formatPart(part, options)
  168. expect(result).toContain("**Tool: bash**")
  169. expect(result).toContain("**Input:**")
  170. expect(result).toContain('"command": "ls"')
  171. expect(result).toContain("**Output:**")
  172. expect(result).toContain("file1.txt")
  173. })
  174. test("formats tool output containing triple backticks without breaking markdown", () => {
  175. const part: Part = {
  176. id: "part_1",
  177. sessionID: "ses_123",
  178. messageID: "msg_123",
  179. type: "tool",
  180. callID: "call_1",
  181. tool: "bash",
  182. state: {
  183. status: "completed",
  184. input: { command: "echo '```hello```'" },
  185. output: "```hello```",
  186. title: "Echo backticks",
  187. metadata: {},
  188. time: { start: 1000, end: 1100 },
  189. },
  190. }
  191. const result = formatPart(part, options)
  192. // The tool header should not be inside a code block
  193. expect(result).toStartWith("**Tool: bash**\n")
  194. // Input and output should each be in their own code blocks
  195. expect(result).toContain("**Input:**\n```json")
  196. expect(result).toContain("**Output:**\n```\n```hello```\n```")
  197. })
  198. test("formats tool part without details when disabled", () => {
  199. const part: Part = {
  200. id: "part_1",
  201. sessionID: "ses_123",
  202. messageID: "msg_123",
  203. type: "tool",
  204. callID: "call_1",
  205. tool: "bash",
  206. state: {
  207. status: "completed",
  208. input: { command: "ls" },
  209. output: "file1.txt",
  210. title: "List files",
  211. metadata: {},
  212. time: { start: 1000, end: 1100 },
  213. },
  214. }
  215. const result = formatPart(part, { ...options, toolDetails: false })
  216. expect(result).toContain("**Tool: bash**")
  217. expect(result).not.toContain("**Input:**")
  218. expect(result).not.toContain("**Output:**")
  219. })
  220. test("formats tool error", () => {
  221. const part: Part = {
  222. id: "part_1",
  223. sessionID: "ses_123",
  224. messageID: "msg_123",
  225. type: "tool",
  226. callID: "call_1",
  227. tool: "bash",
  228. state: {
  229. status: "error",
  230. input: { command: "invalid" },
  231. error: "Command failed",
  232. time: { start: 1000, end: 1100 },
  233. },
  234. }
  235. const result = formatPart(part, options)
  236. expect(result).toContain("**Error:**")
  237. expect(result).toContain("Command failed")
  238. })
  239. })
  240. describe("formatMessage", () => {
  241. const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers }
  242. test("formats user message", () => {
  243. const msg: UserMessage = {
  244. id: "msg_123",
  245. sessionID: "ses_123",
  246. role: "user",
  247. agent: "build",
  248. model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
  249. time: { created: 1000000 },
  250. }
  251. const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }]
  252. const result = formatMessage(msg, parts, options)
  253. expect(result).toContain("## User")
  254. expect(result).toContain("Hello")
  255. })
  256. test("formats assistant message with metadata", () => {
  257. const msg: AssistantMessage = {
  258. id: "msg_123",
  259. sessionID: "ses_123",
  260. role: "assistant",
  261. agent: "build",
  262. modelID: "claude-sonnet-4-20250514",
  263. providerID: "anthropic",
  264. mode: "",
  265. parentID: "msg_parent",
  266. path: { cwd: "/test", root: "/test" },
  267. cost: 0.001,
  268. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  269. time: { created: 1000000, completed: 1005400 },
  270. }
  271. const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
  272. const result = formatMessage(msg, parts, options)
  273. expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)")
  274. expect(result).toContain("Hi there")
  275. })
  276. })
  277. describe("formatTranscript", () => {
  278. test("formats complete transcript", () => {
  279. const session = {
  280. id: "ses_abc123",
  281. title: "Test Session",
  282. time: { created: 1000000000000, updated: 1000000001000 },
  283. }
  284. const messages = [
  285. {
  286. info: {
  287. id: "msg_1",
  288. sessionID: "ses_abc123",
  289. role: "user" as const,
  290. agent: "build",
  291. model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
  292. time: { created: 1000000000000 },
  293. },
  294. parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }],
  295. },
  296. {
  297. info: {
  298. id: "msg_2",
  299. sessionID: "ses_abc123",
  300. role: "assistant" as const,
  301. agent: "build",
  302. modelID: "claude-sonnet-4-20250514",
  303. providerID: "anthropic",
  304. mode: "",
  305. parentID: "msg_1",
  306. path: { cwd: "/test", root: "/test" },
  307. cost: 0.001,
  308. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  309. time: { created: 1000000000100, completed: 1000000000600 },
  310. },
  311. parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }],
  312. },
  313. ]
  314. const options = {
  315. thinking: false,
  316. toolDetails: false,
  317. assistantMetadata: true,
  318. providers,
  319. }
  320. const result = formatTranscript(session, messages, options)
  321. expect(result).toContain("# Test Session")
  322. expect(result).toContain("**Session ID:** ses_abc123")
  323. expect(result).toContain("## User")
  324. expect(result).toContain("Hello")
  325. expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)")
  326. expect(result).toContain("Hi!")
  327. expect(result).toContain("---")
  328. })
  329. test("orders messages by creation time and preserves part order", () => {
  330. const message = (id: string, created: number, parts: string[]) => ({
  331. info: {
  332. id,
  333. sessionID: "ses_abc123",
  334. role: "user" as const,
  335. agent: "build",
  336. model: { providerID: "anthropic", modelID: "claude" },
  337. time: { created },
  338. },
  339. parts: parts.map((text, index) => ({
  340. id: `part_${parts.length - index}`,
  341. sessionID: "ses_abc123",
  342. messageID: id,
  343. type: "text" as const,
  344. text,
  345. })),
  346. })
  347. const result = formatTranscript(
  348. { id: "ses_abc123", title: "Order", time: { created: 1, updated: 2 } },
  349. [message("msg_a", 30, ["third"]), message("msg_z", 10, ["first", "second"])],
  350. { thinking: false, toolDetails: false, assistantMetadata: false },
  351. )
  352. expect(result.indexOf("first")).toBeLessThan(result.indexOf("second"))
  353. expect(result.indexOf("second")).toBeLessThan(result.indexOf("third"))
  354. })
  355. test("falls back to raw model id when provider data is missing", () => {
  356. const session = {
  357. id: "ses_abc123",
  358. title: "Test Session",
  359. time: { created: 1000000000000, updated: 1000000001000 },
  360. }
  361. const messages = [
  362. {
  363. info: {
  364. id: "msg_1",
  365. sessionID: "ses_abc123",
  366. role: "assistant" as const,
  367. agent: "build",
  368. modelID: "claude-sonnet-4-20250514",
  369. providerID: "anthropic",
  370. mode: "",
  371. parentID: "msg_0",
  372. path: { cwd: "/test", root: "/test" },
  373. cost: 0.001,
  374. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  375. time: { created: 1000000000100, completed: 1000000000600 },
  376. },
  377. parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
  378. },
  379. ]
  380. const result = formatTranscript(session, messages, {
  381. thinking: false,
  382. toolDetails: false,
  383. assistantMetadata: true,
  384. })
  385. expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)")
  386. })
  387. test("formats transcript without assistant metadata", () => {
  388. const session = {
  389. id: "ses_abc123",
  390. title: "Test Session",
  391. time: { created: 1000000000000, updated: 1000000001000 },
  392. }
  393. const messages = [
  394. {
  395. info: {
  396. id: "msg_1",
  397. sessionID: "ses_abc123",
  398. role: "assistant" as const,
  399. agent: "build",
  400. modelID: "claude-sonnet-4-20250514",
  401. providerID: "anthropic",
  402. mode: "",
  403. parentID: "msg_0",
  404. path: { cwd: "/test", root: "/test" },
  405. cost: 0.001,
  406. tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
  407. time: { created: 1000000000100, completed: 1000000000600 },
  408. },
  409. parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
  410. },
  411. ]
  412. const options = { thinking: false, toolDetails: false, assistantMetadata: false }
  413. const result = formatTranscript(session, messages, options)
  414. expect(result).toContain("## Assistant\n\n")
  415. expect(result).not.toContain("Build")
  416. expect(result).not.toContain("claude-sonnet-4-20250514")
  417. })
  418. })
  419. })