session-runner-message.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { describe, expect, test } from "bun:test"
  2. import { Message, Model } from "@opencode-ai/llm"
  3. import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
  4. import { ModelV2 } from "@opencode-ai/core/model"
  5. import { ProviderV2 } from "@opencode-ai/core/provider"
  6. import { SessionMessage } from "@opencode-ai/core/session/message"
  7. import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
  8. import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
  9. import { SessionV2 } from "@opencode-ai/core/session"
  10. import { ToolOutput } from "@opencode-ai/core/tool-output"
  11. import { DateTime } from "effect"
  12. const created = DateTime.makeUnsafe(0)
  13. const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
  14. const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
  15. describe("toLLMMessages", () => {
  16. test("maps every top-level V2 Session message type", () => {
  17. const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
  18. const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
  19. const messages = toLLMMessages(
  20. [
  21. new SessionMessage.AgentSwitched({
  22. id: id("agent"),
  23. type: "agent-switched",
  24. agent: "build",
  25. time: { created },
  26. }),
  27. new SessionMessage.ModelSwitched({
  28. id: id("model"),
  29. type: "model-switched",
  30. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  31. time: { created },
  32. }),
  33. new SessionMessage.System({
  34. id: id("system"),
  35. type: "system",
  36. text: "Updated context\n\nOther context",
  37. time: { created },
  38. }),
  39. new SessionMessage.User({
  40. id: id("user"),
  41. type: "user",
  42. text: "Inspect this image",
  43. files: [file],
  44. agents: [new AgentAttachment({ name: "build" })],
  45. references: [reference],
  46. time: { created },
  47. }),
  48. new SessionMessage.Synthetic({
  49. id: id("synthetic"),
  50. type: "synthetic",
  51. sessionID: SessionV2.ID.make("ses_translate"),
  52. text: "Synthetic context",
  53. time: { created },
  54. }),
  55. new SessionMessage.Shell({
  56. id: id("shell"),
  57. type: "shell",
  58. callID: "shell-1",
  59. command: "pwd",
  60. output: "/project",
  61. time: { created, completed: created },
  62. }),
  63. new SessionMessage.Compaction({
  64. id: id("compaction"),
  65. type: "compaction",
  66. reason: "auto",
  67. summary: "Earlier work",
  68. time: { created },
  69. }),
  70. ],
  71. model,
  72. )
  73. expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
  74. expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
  75. expect(messages[1]).toEqual(
  76. Message.make({
  77. id: id("user"),
  78. role: "user",
  79. content: [
  80. { type: "text", text: "Inspect this image" },
  81. { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
  82. ],
  83. metadata: { agents: [{ name: "build" }], references: [reference] },
  84. }),
  85. )
  86. expect(messages.slice(2).map((message) => message.content)).toEqual([
  87. [{ type: "text", text: "Synthetic context" }],
  88. [{ type: "text", text: "Shell command: pwd\n\n/project" }],
  89. [{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
  90. ])
  91. })
  92. test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
  93. const messages = toLLMMessages(
  94. [
  95. new SessionMessage.Assistant({
  96. id: id("assistant"),
  97. type: "assistant",
  98. agent: "build",
  99. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  100. content: [
  101. new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
  102. new SessionMessage.AssistantReasoning({
  103. type: "reasoning",
  104. id: "reasoning-1",
  105. text: "Think",
  106. providerMetadata: { anthropic: { signature: "sig_1" } },
  107. }),
  108. new SessionMessage.AssistantTool({
  109. type: "tool",
  110. id: "pending",
  111. name: "read",
  112. state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
  113. time: { created },
  114. }),
  115. new SessionMessage.AssistantTool({
  116. type: "tool",
  117. id: "running",
  118. name: "read",
  119. state: new SessionMessage.ToolStateRunning({
  120. status: "running",
  121. input: { path: "README.md" },
  122. content: [],
  123. structured: {},
  124. }),
  125. time: { created },
  126. }),
  127. new SessionMessage.AssistantTool({
  128. type: "tool",
  129. id: "completed",
  130. name: "read",
  131. state: new SessionMessage.ToolStateCompleted({
  132. status: "completed",
  133. input: { path: "README.md" },
  134. content: [
  135. new ToolOutput.TextContent({ type: "text", text: "Hello" }),
  136. new ToolOutput.FileContent({
  137. type: "file",
  138. source: { type: "data", data: "aGVsbG8=" },
  139. mime: "image/png",
  140. name: "hello.png",
  141. }),
  142. ],
  143. structured: {},
  144. }),
  145. time: { created, completed: created },
  146. }),
  147. new SessionMessage.AssistantTool({
  148. type: "tool",
  149. id: "hosted",
  150. name: "web_search",
  151. provider: {
  152. executed: true,
  153. metadata: { fake: { continuation: "hosted-call" } },
  154. resultMetadata: { fake: { continuation: "hosted-result" } },
  155. },
  156. state: new SessionMessage.ToolStateCompleted({
  157. status: "completed",
  158. input: { query: "Effect" },
  159. content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
  160. structured: {},
  161. }),
  162. time: { created, completed: created },
  163. }),
  164. new SessionMessage.AssistantTool({
  165. type: "tool",
  166. id: "hosted-failed",
  167. name: "write",
  168. provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
  169. state: new SessionMessage.ToolStateError({
  170. status: "error",
  171. input: { path: "README.md" },
  172. content: [],
  173. structured: {},
  174. error: { type: "unknown", message: "Denied" },
  175. }),
  176. time: { created, completed: created },
  177. }),
  178. ],
  179. time: { created, completed: created },
  180. }),
  181. ],
  182. model,
  183. )
  184. expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
  185. expect(messages[0]?.content).toEqual([
  186. { type: "text", text: "Checking" },
  187. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
  188. { type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
  189. { type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
  190. {
  191. type: "tool-call",
  192. id: "completed",
  193. name: "read",
  194. input: { path: "README.md" },
  195. },
  196. {
  197. type: "tool-call",
  198. id: "hosted",
  199. name: "web_search",
  200. input: { query: "Effect" },
  201. providerExecuted: true,
  202. providerMetadata: { fake: { continuation: "hosted-call" } },
  203. },
  204. {
  205. type: "tool-result",
  206. id: "hosted",
  207. name: "web_search",
  208. providerExecuted: true,
  209. providerMetadata: { fake: { continuation: "hosted-result" } },
  210. result: { type: "text", value: "Found it" },
  211. },
  212. {
  213. type: "tool-call",
  214. id: "hosted-failed",
  215. name: "write",
  216. input: { path: "README.md" },
  217. providerExecuted: true,
  218. providerMetadata: { fake: { continuation: "failed" } },
  219. },
  220. {
  221. type: "tool-result",
  222. id: "hosted-failed",
  223. name: "write",
  224. providerExecuted: true,
  225. providerMetadata: { fake: { continuation: "failed" } },
  226. result: {
  227. type: "error",
  228. value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
  229. },
  230. },
  231. ])
  232. expect(messages[1]?.content).toEqual([
  233. {
  234. type: "tool-result",
  235. id: "completed",
  236. name: "read",
  237. result: {
  238. type: "content",
  239. value: [
  240. { type: "text", text: "Hello" },
  241. { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
  242. ],
  243. },
  244. },
  245. ])
  246. })
  247. test("restores OpenAI encrypted reasoning metadata", () => {
  248. const messages = toLLMMessages(
  249. [
  250. new SessionMessage.Assistant({
  251. id: id("assistant-openai-reasoning"),
  252. type: "assistant",
  253. agent: "build",
  254. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  255. content: [
  256. new SessionMessage.AssistantReasoning({
  257. type: "reasoning",
  258. id: "reasoning-openai",
  259. text: "Think",
  260. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  261. }),
  262. ],
  263. time: { created, completed: created },
  264. }),
  265. ],
  266. model,
  267. )
  268. expect(messages[0]?.content).toEqual([
  269. {
  270. type: "reasoning",
  271. text: "Think",
  272. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  273. },
  274. ])
  275. })
  276. test("drops provider-native continuation metadata after a model switch", () => {
  277. const messages = toLLMMessages(
  278. [
  279. new SessionMessage.Assistant({
  280. id: id("assistant-old-model"),
  281. type: "assistant",
  282. agent: "build",
  283. model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
  284. content: [
  285. new SessionMessage.AssistantReasoning({
  286. type: "reasoning",
  287. id: "reasoning-old-model",
  288. text: "Visible thought",
  289. providerMetadata: { anthropic: { signature: "sig_old" } },
  290. }),
  291. new SessionMessage.AssistantTool({
  292. type: "tool",
  293. id: "hosted-old-model",
  294. name: "web_search",
  295. provider: {
  296. executed: true,
  297. metadata: { openai: { itemId: "hosted-old-model" } },
  298. resultMetadata: { openai: { itemId: "hosted-old-model" } },
  299. },
  300. state: new SessionMessage.ToolStateCompleted({
  301. status: "completed",
  302. input: { query: "Effect" },
  303. content: [],
  304. structured: {},
  305. result: { type: "json", value: { status: "completed" } },
  306. }),
  307. time: { created, completed: created },
  308. }),
  309. new SessionMessage.AssistantTool({
  310. type: "tool",
  311. id: "local-old-model",
  312. name: "read",
  313. provider: {
  314. executed: false,
  315. metadata: { fake: { call: "old" } },
  316. resultMetadata: { fake: { result: "old" } },
  317. },
  318. state: new SessionMessage.ToolStateCompleted({
  319. status: "completed",
  320. input: { path: "README.md" },
  321. content: [],
  322. structured: { text: "Hello" },
  323. }),
  324. time: { created, completed: created },
  325. }),
  326. ],
  327. time: { created, completed: created },
  328. }),
  329. ],
  330. model,
  331. )
  332. expect(messages[0]?.content).toEqual([
  333. { type: "text", text: "Visible thought" },
  334. {
  335. type: "tool-call",
  336. id: "hosted-old-model",
  337. name: "web_search",
  338. input: { query: "Effect" },
  339. providerExecuted: true,
  340. providerMetadata: undefined,
  341. },
  342. {
  343. type: "tool-result",
  344. id: "hosted-old-model",
  345. name: "web_search",
  346. result: { type: "json", value: { status: "completed" } },
  347. providerExecuted: true,
  348. cache: undefined,
  349. metadata: undefined,
  350. providerMetadata: undefined,
  351. },
  352. {
  353. type: "tool-call",
  354. id: "local-old-model",
  355. name: "read",
  356. input: { path: "README.md" },
  357. providerExecuted: false,
  358. providerMetadata: undefined,
  359. },
  360. ])
  361. expect(messages[1]?.content).toEqual([
  362. {
  363. type: "tool-result",
  364. id: "local-old-model",
  365. name: "read",
  366. result: { type: "json", value: { text: "Hello" } },
  367. providerExecuted: false,
  368. cache: undefined,
  369. metadata: undefined,
  370. providerMetadata: undefined,
  371. },
  372. ])
  373. })
  374. })