session-runner-message.test.ts 13 KB

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