session.shared.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
  2. import { OpenCode, type SessionMessageUser } from "@opencode-ai/client/promise"
  3. import {
  4. createSession,
  5. resolveCurrentSession,
  6. sessionHistory,
  7. sessionVariant,
  8. type RunSession,
  9. type SessionMessages,
  10. } from "../../src/mini/session.shared"
  11. const model = {
  12. providerID: "openai",
  13. modelID: "gpt-5",
  14. }
  15. afterEach(() => {
  16. mock.restore()
  17. })
  18. function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): SessionMessageUser {
  19. return {
  20. id,
  21. type: "user",
  22. text,
  23. time: { created: 1 },
  24. ...input,
  25. }
  26. }
  27. describe("run session shared", () => {
  28. test("builds user prompts from projected text and attachments", () => {
  29. const msgs: SessionMessages = [
  30. userMessage("msg-user-1", "look @scan @note.ts", {
  31. agents: [{ name: "scan", mention: { start: 5, end: 10, text: "@scan" } }],
  32. files: [
  33. {
  34. data: "",
  35. mime: "text/plain",
  36. source: { type: "uri", uri: "file:///tmp/note.ts" },
  37. mention: { start: 11, end: 19, text: "@note.ts" },
  38. },
  39. ],
  40. }),
  41. ]
  42. const out = createSession(msgs)
  43. expect(out.first).toBe(false)
  44. expect(out.turns).toHaveLength(1)
  45. expect(out.turns[0]?.prompt.text).toBe("look @scan @note.ts")
  46. expect(out.turns[0]?.prompt.parts).toEqual([
  47. {
  48. type: "file",
  49. mime: "text/plain",
  50. filename: undefined,
  51. url: "file:///tmp/note.ts",
  52. source: {
  53. type: "file",
  54. path: "file:///tmp/note.ts",
  55. text: {
  56. start: 11,
  57. end: 19,
  58. value: "@note.ts",
  59. },
  60. },
  61. },
  62. {
  63. type: "agent",
  64. name: "scan",
  65. source: {
  66. start: 5,
  67. end: 10,
  68. value: "@scan",
  69. },
  70. },
  71. ])
  72. })
  73. test("leaves attachment sources undefined when projected mentions are absent", () => {
  74. const out = createSession([
  75. userMessage("msg-user-1", "look @scan @note.ts", {
  76. agents: [{ name: "scan" }],
  77. files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/note.ts" } }],
  78. }),
  79. ])
  80. expect(out.turns[0]?.prompt).toEqual({
  81. text: "look @scan @note.ts",
  82. parts: [
  83. {
  84. type: "file",
  85. mime: "text/plain",
  86. filename: undefined,
  87. url: "file:///tmp/note.ts",
  88. source: undefined,
  89. },
  90. {
  91. type: "agent",
  92. name: "scan",
  93. source: undefined,
  94. },
  95. ],
  96. })
  97. })
  98. test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
  99. const parts = [
  100. {
  101. type: "agent" as const,
  102. name: "scan",
  103. source: {
  104. start: 0,
  105. end: 5,
  106. value: "@scan",
  107. },
  108. },
  109. ]
  110. const session: RunSession = {
  111. first: false,
  112. turns: [
  113. { prompt: { text: "one", parts }, provider: "openai", model: "gpt-5", variant: "high" },
  114. { prompt: { text: "one", parts: structuredClone(parts) }, provider: "openai", model: "gpt-5", variant: "high" },
  115. { prompt: { text: " ", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  116. { prompt: { text: "two", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  117. ],
  118. }
  119. const out = sessionHistory(session)
  120. expect(out.map((item) => item.text)).toEqual(["one", "two"])
  121. expect(out[0]?.parts).toEqual(parts)
  122. expect(out[0]?.parts).not.toBe(parts)
  123. expect(out[0]?.parts[0]).not.toBe(parts[0])
  124. })
  125. test("returns the latest matching variant for the active model", () => {
  126. const session: RunSession = {
  127. first: false,
  128. turns: [
  129. { prompt: { text: "one", parts: [] }, provider: "openai", model: "gpt-5", variant: "high" },
  130. { prompt: { text: "two", parts: [] }, provider: "anthropic", model: "sonnet", variant: "max" },
  131. { prompt: { text: "three", parts: [] }, provider: "openai", model: "gpt-5", variant: undefined },
  132. ],
  133. }
  134. expect(sessionVariant(session, model)).toBeUndefined()
  135. session.turns.push({
  136. prompt: { text: "four", parts: [] },
  137. provider: "openai",
  138. model: "gpt-5",
  139. variant: "minimal",
  140. })
  141. expect(sessionVariant(session, model)).toBe("minimal")
  142. })
  143. test("restores current prompt history from stored text and file references", async () => {
  144. const client = OpenCode.make({ baseUrl: "https://opencode.test" })
  145. spyOn(client.message, "list").mockImplementation(() =>
  146. Promise.resolve({
  147. data: [
  148. {
  149. id: "msg_prompt",
  150. type: "user",
  151. text: "Review @note.ts",
  152. files: [
  153. {
  154. data: "",
  155. mime: "text/plain",
  156. name: "note.ts",
  157. source: { type: "uri", uri: "file:///tmp/note.ts" },
  158. mention: { start: 7, end: 15, text: "@note.ts" },
  159. },
  160. ],
  161. agents: [],
  162. time: { created: 1 },
  163. },
  164. ],
  165. cursor: {},
  166. }),
  167. )
  168. spyOn(client.session, "get").mockImplementation(() =>
  169. Promise.resolve({
  170. id: "ses_1",
  171. title: "Session",
  172. projectID: "proj_1",
  173. location: { directory: "/tmp" },
  174. time: { created: 1, updated: 1 },
  175. cost: 0,
  176. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  177. model: { providerID: "openai", id: "gpt-5", variant: "high" },
  178. }),
  179. )
  180. const controller = new AbortController()
  181. const out = await resolveCurrentSession(client, "ses_1", controller.signal)
  182. expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
  183. expect(out.variant).toBe("high")
  184. expect(out.turns[0]?.prompt).toEqual({
  185. text: "Review @note.ts",
  186. parts: [
  187. {
  188. type: "file",
  189. url: "file:///tmp/note.ts",
  190. mime: "text/plain",
  191. filename: "note.ts",
  192. source: {
  193. type: "file",
  194. path: "note.ts",
  195. text: { start: 7, end: 15, value: "@note.ts" },
  196. },
  197. },
  198. ],
  199. })
  200. expect(client.message.list).toHaveBeenCalledWith(
  201. { sessionID: "ses_1", limit: 200, order: "desc" },
  202. { signal: controller.signal },
  203. )
  204. expect(client.session.get).toHaveBeenCalledWith({ sessionID: "ses_1" }, { signal: controller.signal })
  205. })
  206. })