session.shared.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. createSession,
  4. sessionHistory,
  5. sessionVariant,
  6. type RunSession,
  7. type SessionMessages,
  8. } from "@/cli/cmd/run/session.shared"
  9. type Message = SessionMessages[number]
  10. type Part = Message["parts"][number]
  11. type TextPart = Extract<Part, { type: "text" }>
  12. type AgentPart = Extract<Part, { type: "agent" }>
  13. type FilePart = Extract<Part, { type: "file" }>
  14. const model = {
  15. providerID: "openai",
  16. modelID: "gpt-5",
  17. }
  18. function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
  19. return {
  20. info: {
  21. id,
  22. sessionID: "session-1",
  23. role: "user",
  24. time: {
  25. created: 1,
  26. },
  27. agent: "build",
  28. model: {
  29. ...model,
  30. variant,
  31. },
  32. },
  33. parts,
  34. }
  35. }
  36. function assistantMessage(id: string, parts: Message["parts"]): Message {
  37. return {
  38. info: {
  39. id,
  40. sessionID: "session-1",
  41. role: "assistant",
  42. time: {
  43. created: 1,
  44. },
  45. parentID: "msg-user-1",
  46. modelID: "gpt-5",
  47. providerID: "openai",
  48. mode: "chat",
  49. agent: "build",
  50. path: {
  51. cwd: "/tmp",
  52. root: "/tmp",
  53. },
  54. cost: 0,
  55. tokens: {
  56. input: 1,
  57. output: 1,
  58. reasoning: 0,
  59. cache: {
  60. read: 0,
  61. write: 0,
  62. },
  63. },
  64. },
  65. parts,
  66. }
  67. }
  68. function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
  69. return {
  70. id,
  71. sessionID: "session-1",
  72. messageID,
  73. type: "text",
  74. text,
  75. synthetic: input.synthetic,
  76. }
  77. }
  78. function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
  79. return {
  80. id,
  81. sessionID: "session-1",
  82. messageID,
  83. type: "agent",
  84. name,
  85. source,
  86. }
  87. }
  88. function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
  89. return {
  90. id,
  91. sessionID: "session-1",
  92. messageID,
  93. type: "file",
  94. mime: input.mime ?? "text/plain",
  95. filename: input.filename,
  96. url,
  97. source: input.source,
  98. }
  99. }
  100. describe("run session shared", () => {
  101. test("builds user prompt text from text, file, and agent parts", () => {
  102. const msgs: SessionMessages = [
  103. assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
  104. userMessage("msg-user-1", [
  105. textPart("txt-user-1", "msg-user-1", "look @scan"),
  106. textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
  107. agentPart("agent-user-1", "msg-user-1", "scan", {
  108. start: 5,
  109. end: 10,
  110. value: "@scan",
  111. }),
  112. filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
  113. ]),
  114. ]
  115. const out = createSession(msgs)
  116. expect(out.first).toBe(false)
  117. expect(out.turns).toHaveLength(1)
  118. expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
  119. expect(out.turns[0]?.prompt.parts).toEqual([
  120. {
  121. type: "agent",
  122. name: "scan",
  123. source: {
  124. start: 5,
  125. end: 10,
  126. value: "@scan",
  127. },
  128. },
  129. {
  130. type: "file",
  131. mime: "text/plain",
  132. filename: undefined,
  133. url: "file:///tmp/note.ts",
  134. source: {
  135. type: "file",
  136. path: "file:///tmp/note.ts",
  137. text: {
  138. start: 11,
  139. end: 19,
  140. value: "@note.ts",
  141. },
  142. },
  143. },
  144. ])
  145. })
  146. test("reuses existing mentions when file and agent parts have no source", () => {
  147. const out = createSession([
  148. userMessage("msg-user-1", [
  149. textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
  150. agentPart("agent-user-1", "msg-user-1", "scan"),
  151. filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
  152. ]),
  153. ])
  154. expect(out.turns[0]?.prompt).toEqual({
  155. text: "look @scan @note.ts",
  156. parts: [
  157. {
  158. type: "agent",
  159. name: "scan",
  160. source: {
  161. start: 5,
  162. end: 10,
  163. value: "@scan",
  164. },
  165. },
  166. {
  167. type: "file",
  168. mime: "text/plain",
  169. filename: undefined,
  170. url: "file:///tmp/note.ts",
  171. source: {
  172. type: "file",
  173. path: "file:///tmp/note.ts",
  174. text: {
  175. start: 11,
  176. end: 19,
  177. value: "@note.ts",
  178. },
  179. },
  180. },
  181. ],
  182. })
  183. })
  184. test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
  185. const parts = [
  186. {
  187. type: "agent" as const,
  188. name: "scan",
  189. source: {
  190. start: 0,
  191. end: 5,
  192. value: "@scan",
  193. },
  194. },
  195. ]
  196. const session: RunSession = {
  197. first: false,
  198. turns: [
  199. { prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
  200. { prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
  201. { prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  202. { prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  203. ],
  204. }
  205. const out = sessionHistory(session)
  206. expect(out.map((item) => item.text)).toEqual(["one", "two"])
  207. expect(out[0]?.parts).toEqual(parts)
  208. expect(out[0]?.parts).not.toBe(parts)
  209. expect(out[0]?.parts[0]).not.toBe(parts[0])
  210. })
  211. test("returns the latest matching variant for the active model", () => {
  212. const session: RunSession = {
  213. first: false,
  214. turns: [
  215. { prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  216. { prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
  217. { prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  218. ],
  219. }
  220. expect(sessionVariant(session, model)).toBeUndefined()
  221. session.turns.push({
  222. prompt: { text: "four", parts: [] },
  223. provider: "openai",
  224. model: "gpt-5",
  225. variant: "minimal",
  226. })
  227. expect(sessionVariant(session, model)).toBe("minimal")
  228. })
  229. })