github-action.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { test, expect, describe } from "bun:test"
  2. import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
  3. import type { MessageV2 } from "../../src/session/message-v2"
  4. import { SessionID, MessageID, PartID } from "../../src/session/schema"
  5. // Helper to create minimal valid parts
  6. function createTextPart(text: string): MessageV2.Part {
  7. return {
  8. id: PartID.ascending(),
  9. sessionID: SessionID.make("s"),
  10. messageID: MessageID.make("m"),
  11. type: "text" as const,
  12. text,
  13. }
  14. }
  15. function createReasoningPart(text: string): MessageV2.Part {
  16. return {
  17. id: PartID.ascending(),
  18. sessionID: SessionID.make("s"),
  19. messageID: MessageID.make("m"),
  20. type: "reasoning" as const,
  21. text,
  22. time: { start: 0 },
  23. }
  24. }
  25. function createToolPart(tool: string, title: string, status: "completed" | "running" = "completed"): MessageV2.Part {
  26. if (status === "completed") {
  27. return {
  28. id: PartID.ascending(),
  29. sessionID: SessionID.make("s"),
  30. messageID: MessageID.make("m"),
  31. type: "tool" as const,
  32. callID: "c1",
  33. tool,
  34. state: {
  35. status: "completed",
  36. input: {},
  37. output: "",
  38. title,
  39. metadata: {},
  40. time: { start: 0, end: 1 },
  41. },
  42. }
  43. }
  44. return {
  45. id: PartID.ascending(),
  46. sessionID: SessionID.make("s"),
  47. messageID: MessageID.make("m"),
  48. type: "tool" as const,
  49. callID: "c1",
  50. tool,
  51. state: {
  52. status: "running",
  53. input: {},
  54. time: { start: 0 },
  55. },
  56. }
  57. }
  58. function createStepStartPart(): MessageV2.Part {
  59. return {
  60. id: PartID.ascending(),
  61. sessionID: SessionID.make("s"),
  62. messageID: MessageID.make("m"),
  63. type: "step-start" as const,
  64. }
  65. }
  66. function createStepFinishPart(): MessageV2.Part {
  67. return {
  68. id: PartID.ascending(),
  69. sessionID: SessionID.make("s"),
  70. messageID: MessageID.make("m"),
  71. type: "step-finish" as const,
  72. reason: "done",
  73. cost: 0,
  74. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  75. }
  76. }
  77. describe("extractResponseText", () => {
  78. test("returns text from text part", () => {
  79. const parts = [createTextPart("Hello world")]
  80. expect(extractResponseText(parts)).toBe("Hello world")
  81. })
  82. test("returns last text part when multiple exist", () => {
  83. const parts = [createTextPart("First"), createTextPart("Last")]
  84. expect(extractResponseText(parts)).toBe("Last")
  85. })
  86. test("returns text even when tool parts follow", () => {
  87. const parts = [createTextPart("I'll help with that."), createToolPart("todowrite", "3 todos")]
  88. expect(extractResponseText(parts)).toBe("I'll help with that.")
  89. })
  90. test("returns null for reasoning-only response (signals summary needed)", () => {
  91. const parts = [createReasoningPart("Let me think about this...")]
  92. expect(extractResponseText(parts)).toBeNull()
  93. })
  94. test("returns null for tool-only response (signals summary needed)", () => {
  95. // This is the exact scenario from the bug report - todowrite with no text
  96. const parts = [createToolPart("todowrite", "8 todos")]
  97. expect(extractResponseText(parts)).toBeNull()
  98. })
  99. test("returns null for multiple completed tools", () => {
  100. const parts = [
  101. createToolPart("read", "src/file.ts"),
  102. createToolPart("edit", "src/file.ts"),
  103. createToolPart("bash", "bun test"),
  104. ]
  105. expect(extractResponseText(parts)).toBeNull()
  106. })
  107. test("returns null for running tool parts (signals summary needed)", () => {
  108. const parts = [createToolPart("bash", "", "running")]
  109. expect(extractResponseText(parts)).toBeNull()
  110. })
  111. test("throws on empty array", () => {
  112. expect(() => extractResponseText([])).toThrow("no parts returned")
  113. })
  114. test("returns null for step-start only", () => {
  115. const parts = [createStepStartPart()]
  116. expect(extractResponseText(parts)).toBeNull()
  117. })
  118. test("returns null for step-finish only", () => {
  119. const parts = [createStepFinishPart()]
  120. expect(extractResponseText(parts)).toBeNull()
  121. })
  122. test("returns null for step-start and step-finish", () => {
  123. const parts = [createStepStartPart(), createStepFinishPart()]
  124. expect(extractResponseText(parts)).toBeNull()
  125. })
  126. test("returns text from multi-step response", () => {
  127. const parts = [
  128. createStepStartPart(),
  129. createToolPart("read", "src/file.ts"),
  130. createTextPart("Done"),
  131. createStepFinishPart(),
  132. ]
  133. expect(extractResponseText(parts)).toBe("Done")
  134. })
  135. test("prefers text over reasoning when both present", () => {
  136. const parts = [createReasoningPart("Internal thinking..."), createTextPart("Final answer")]
  137. expect(extractResponseText(parts)).toBe("Final answer")
  138. })
  139. test("prefers text over tools when both present", () => {
  140. const parts = [createToolPart("read", "src/file.ts"), createTextPart("Here's what I found")]
  141. expect(extractResponseText(parts)).toBe("Here's what I found")
  142. })
  143. })
  144. describe("formatPromptTooLargeError", () => {
  145. test("formats error without files", () => {
  146. const result = formatPromptTooLargeError([])
  147. expect(result).toBe("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
  148. })
  149. test("formats error with files (base64 content)", () => {
  150. // Base64 is ~33% larger than original, so we multiply by 0.75 to get original size
  151. // 400 KB base64 = 300 KB original, 200 KB base64 = 150 KB original
  152. const files = [
  153. { filename: "screenshot.png", content: "a".repeat(400 * 1024) },
  154. { filename: "diagram.png", content: "b".repeat(200 * 1024) },
  155. ]
  156. const result = formatPromptTooLargeError(files)
  157. expect(result).toStartWith("PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.")
  158. expect(result).toInclude("Files in prompt:")
  159. expect(result).toInclude("screenshot.png (300 KB)")
  160. expect(result).toInclude("diagram.png (150 KB)")
  161. })
  162. test("lists all files when multiple present", () => {
  163. // Base64 sizes: 4KB -> 3KB, 8KB -> 6KB, 12KB -> 9KB
  164. const files = [
  165. { filename: "img1.png", content: "x".repeat(4 * 1024) },
  166. { filename: "img2.jpg", content: "y".repeat(8 * 1024) },
  167. { filename: "img3.gif", content: "z".repeat(12 * 1024) },
  168. ]
  169. const result = formatPromptTooLargeError(files)
  170. expect(result).toInclude("img1.png (3 KB)")
  171. expect(result).toInclude("img2.jpg (6 KB)")
  172. expect(result).toInclude("img3.gif (9 KB)")
  173. })
  174. })