session-runner-message.test.ts 13 KB

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