github-action.test.ts 6.5 KB

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