session.shared.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpenCode } from "@opencode-ai/client/promise"
  3. import {
  4. createSession,
  5. resolveCurrentSession,
  6. sessionHistory,
  7. sessionVariant,
  8. type RunSession,
  9. type SessionMessages,
  10. } from "@opencode-ai/cli/mini/session.shared"
  11. type Message = SessionMessages[number]
  12. type Part = Message["parts"][number]
  13. type TextPart = Extract<Part, { type: "text" }>
  14. type AgentPart = Extract<Part, { type: "agent" }>
  15. type FilePart = Extract<Part, { type: "file" }>
  16. const model = {
  17. providerID: "openai",
  18. modelID: "gpt-5",
  19. }
  20. afterEach(() => {
  21. mock.restore()
  22. })
  23. function userMessage(id: string, parts: Message["parts"], variant = "high"): Message {
  24. return {
  25. info: {
  26. id,
  27. sessionID: "session-1",
  28. role: "user",
  29. time: {
  30. created: 1,
  31. },
  32. agent: "build",
  33. model: {
  34. ...model,
  35. variant,
  36. },
  37. },
  38. parts,
  39. }
  40. }
  41. function assistantMessage(id: string, parts: Message["parts"]): Message {
  42. return {
  43. info: {
  44. id,
  45. sessionID: "session-1",
  46. role: "assistant",
  47. time: {
  48. created: 1,
  49. },
  50. parentID: "msg-user-1",
  51. modelID: "gpt-5",
  52. providerID: "openai",
  53. mode: "chat",
  54. agent: "build",
  55. path: {
  56. cwd: "/tmp",
  57. root: "/tmp",
  58. },
  59. cost: 0,
  60. tokens: {
  61. input: 1,
  62. output: 1,
  63. reasoning: 0,
  64. cache: {
  65. read: 0,
  66. write: 0,
  67. },
  68. },
  69. },
  70. parts,
  71. }
  72. }
  73. function textPart(id: string, messageID: string, text: string, input: Partial<TextPart> = {}): TextPart {
  74. return {
  75. id,
  76. sessionID: "session-1",
  77. messageID,
  78. type: "text",
  79. text,
  80. synthetic: input.synthetic,
  81. }
  82. }
  83. function agentPart(id: string, messageID: string, name: string, source?: AgentPart["source"]): AgentPart {
  84. return {
  85. id,
  86. sessionID: "session-1",
  87. messageID,
  88. type: "agent",
  89. name,
  90. source,
  91. }
  92. }
  93. function filePart(id: string, messageID: string, url: string, input: Partial<FilePart> = {}): FilePart {
  94. return {
  95. id,
  96. sessionID: "session-1",
  97. messageID,
  98. type: "file",
  99. mime: input.mime ?? "text/plain",
  100. filename: input.filename,
  101. url,
  102. source: input.source,
  103. }
  104. }
  105. describe("run session shared", () => {
  106. test("builds user prompt text from text, file, and agent parts", () => {
  107. const msgs: SessionMessages = [
  108. assistantMessage("msg-assistant-1", [textPart("txt-assistant-1", "msg-assistant-1", "ignore me")]),
  109. userMessage("msg-user-1", [
  110. textPart("txt-user-1", "msg-user-1", "look @scan"),
  111. textPart("txt-user-2", "msg-user-1", "hidden", { synthetic: true }),
  112. agentPart("agent-user-1", "msg-user-1", "scan", {
  113. start: 5,
  114. end: 10,
  115. value: "@scan",
  116. }),
  117. filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
  118. ]),
  119. ]
  120. const out = createSession(msgs)
  121. expect(out.first).toBe(false)
  122. expect(out.turns).toHaveLength(1)
  123. expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
  124. expect(out.turns[0]?.prompt.parts).toEqual([
  125. {
  126. type: "agent",
  127. name: "scan",
  128. source: {
  129. start: 5,
  130. end: 10,
  131. value: "@scan",
  132. },
  133. },
  134. {
  135. type: "file",
  136. mime: "text/plain",
  137. filename: undefined,
  138. url: "file:///tmp/note.ts",
  139. source: {
  140. type: "file",
  141. path: "file:///tmp/note.ts",
  142. text: {
  143. start: 11,
  144. end: 19,
  145. value: "@note.ts",
  146. },
  147. },
  148. },
  149. ])
  150. })
  151. test("reuses existing mentions when file and agent parts have no source", () => {
  152. const out = createSession([
  153. userMessage("msg-user-1", [
  154. textPart("txt-user-1", "msg-user-1", "look @scan @note.ts"),
  155. agentPart("agent-user-1", "msg-user-1", "scan"),
  156. filePart("file-user-1", "msg-user-1", "file:///tmp/note.ts"),
  157. ]),
  158. ])
  159. expect(out.turns[0]?.prompt).toEqual({
  160. text: "look @scan @note.ts",
  161. parts: [
  162. {
  163. type: "agent",
  164. name: "scan",
  165. source: {
  166. start: 5,
  167. end: 10,
  168. value: "@scan",
  169. },
  170. },
  171. {
  172. type: "file",
  173. mime: "text/plain",
  174. filename: undefined,
  175. url: "file:///tmp/note.ts",
  176. source: {
  177. type: "file",
  178. path: "file:///tmp/note.ts",
  179. text: {
  180. start: 11,
  181. end: 19,
  182. value: "@note.ts",
  183. },
  184. },
  185. },
  186. ],
  187. })
  188. })
  189. test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
  190. const parts = [
  191. {
  192. type: "agent" as const,
  193. name: "scan",
  194. source: {
  195. start: 0,
  196. end: 5,
  197. value: "@scan",
  198. },
  199. },
  200. ]
  201. const session: RunSession = {
  202. first: false,
  203. turns: [
  204. { prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
  205. { prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
  206. { prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  207. { prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  208. ],
  209. }
  210. const out = sessionHistory(session)
  211. expect(out.map((item) => item.text)).toEqual(["one", "two"])
  212. expect(out[0]?.parts).toEqual(parts)
  213. expect(out[0]?.parts).not.toBe(parts)
  214. expect(out[0]?.parts[0]).not.toBe(parts[0])
  215. })
  216. test("returns the latest matching variant for the active model", () => {
  217. const session: RunSession = {
  218. first: false,
  219. turns: [
  220. { prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  221. { prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
  222. { prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  223. ],
  224. }
  225. expect(sessionVariant(session, model)).toBeUndefined()
  226. session.turns.push({
  227. prompt: { text: "four", parts: [] },
  228. provider: "openai",
  229. model: "gpt-5",
  230. variant: "minimal",
  231. })
  232. expect(sessionVariant(session, model)).toBe("minimal")
  233. })
  234. test("restores current prompt history from stored text and file references", async () => {
  235. const client = OpenCode.make({ baseUrl: "https://opencode.test" })
  236. spyOn(client.message, "list").mockImplementation(() =>
  237. Promise.resolve({
  238. data: [
  239. {
  240. id: "msg_prompt",
  241. type: "user",
  242. text: "Review @note.ts",
  243. files: [
  244. {
  245. uri: "file:///tmp/note.ts",
  246. mime: "text/plain",
  247. name: "note.ts",
  248. source: { start: 7, end: 15, text: "@note.ts" },
  249. },
  250. ],
  251. agents: [],
  252. time: { created: 1 },
  253. },
  254. ],
  255. cursor: {},
  256. }),
  257. )
  258. spyOn(client.session, "get").mockImplementation(() =>
  259. Promise.resolve({
  260. id: "ses_1",
  261. title: "Session",
  262. projectID: "proj_1",
  263. location: { directory: "/tmp" },
  264. time: { created: 1, updated: 1 },
  265. cost: 0,
  266. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  267. model: { providerID: "openai", id: "gpt-5", variant: "high" },
  268. }),
  269. )
  270. const out = await resolveCurrentSession(client, "ses_1")
  271. expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
  272. expect(out.variant).toBe("high")
  273. expect(out.turns[0]?.prompt).toEqual({
  274. text: "Review @note.ts",
  275. parts: [
  276. {
  277. type: "file",
  278. url: "file:///tmp/note.ts",
  279. mime: "text/plain",
  280. filename: "note.ts",
  281. source: {
  282. type: "file",
  283. path: "note.ts",
  284. text: { start: 7, end: 15, value: "@note.ts" },
  285. },
  286. },
  287. ],
  288. })
  289. })
  290. })