session-runner-message.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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 } 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 { DateTime } from "effect"
  11. const created = DateTime.makeUnsafe(0)
  12. const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
  13. const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
  14. describe("toLLMMessages", () => {
  15. test("omits empty assistant turns", () => {
  16. const assistant = (value: string, content: SessionMessage.Assistant["content"]) =>
  17. SessionMessage.Assistant.make({
  18. id: id(value),
  19. type: "assistant",
  20. agent: "build",
  21. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  22. content,
  23. time: { created, completed: created },
  24. })
  25. const messages = toLLMMessages(
  26. [
  27. assistant("empty", []),
  28. assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]),
  29. assistant("empty-reasoning", [
  30. SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }),
  31. ]),
  32. assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
  33. assistant("reasoning", [
  34. SessionMessage.AssistantReasoning.make({
  35. type: "reasoning",
  36. id: "reasoning",
  37. text: "",
  38. providerMetadata: { anthropic: { signature: "sig_1" } },
  39. }),
  40. ]),
  41. ],
  42. model,
  43. )
  44. expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")])
  45. })
  46. test("maps every top-level V2 Session message type", () => {
  47. const file = FileAttachment.make({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
  48. const messages = toLLMMessages(
  49. [
  50. SessionMessage.AgentSwitched.make({
  51. id: id("agent"),
  52. type: "agent-switched",
  53. agent: "build",
  54. time: { created },
  55. }),
  56. SessionMessage.ModelSwitched.make({
  57. id: id("model"),
  58. type: "model-switched",
  59. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  60. time: { created },
  61. }),
  62. SessionMessage.System.make({
  63. id: id("system"),
  64. type: "system",
  65. text: "Updated context\n\nOther context",
  66. time: { created },
  67. }),
  68. SessionMessage.User.make({
  69. id: id("user"),
  70. type: "user",
  71. text: "Inspect this image",
  72. files: [file],
  73. agents: [AgentAttachment.make({ name: "build" })],
  74. time: { created },
  75. }),
  76. SessionMessage.Synthetic.make({
  77. id: id("synthetic"),
  78. type: "synthetic",
  79. sessionID: SessionV2.ID.make("ses_translate"),
  80. text: "Synthetic context",
  81. time: { created },
  82. }),
  83. SessionMessage.Shell.make({
  84. id: id("shell"),
  85. type: "shell",
  86. callID: "shell-1",
  87. command: "pwd",
  88. output: "/project",
  89. time: { created, completed: created },
  90. }),
  91. SessionMessage.Compaction.make({
  92. id: id("compaction"),
  93. type: "compaction",
  94. reason: "auto",
  95. summary: "Earlier work",
  96. recent: "Recent work",
  97. time: { created },
  98. }),
  99. ],
  100. model,
  101. )
  102. expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
  103. expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
  104. expect(messages[1]).toEqual(
  105. Message.make({
  106. id: id("user"),
  107. role: "user",
  108. content: [
  109. { type: "text", text: "Inspect this image" },
  110. { type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
  111. ],
  112. metadata: { agents: [{ name: "build" }] },
  113. }),
  114. )
  115. expect(messages.slice(2).map((message) => message.content)).toEqual([
  116. [{ type: "text", text: "Synthetic context" }],
  117. [{ type: "text", text: "Shell command: pwd\n\n/project" }],
  118. [
  119. {
  120. type: "text",
  121. text: `<conversation-checkpoint>
  122. The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
  123. <summary>
  124. Earlier work
  125. </summary>
  126. <recent-context>
  127. Recent work
  128. </recent-context>
  129. </conversation-checkpoint>`,
  130. },
  131. ],
  132. ])
  133. })
  134. test("replays durable tool media into canonical tool messages without structured base64", () => {
  135. const messages = toLLMMessages(
  136. [
  137. SessionMessage.Assistant.make({
  138. id: id("assistant"),
  139. type: "assistant",
  140. agent: "build",
  141. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  142. content: [
  143. SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }),
  144. SessionMessage.AssistantReasoning.make({
  145. type: "reasoning",
  146. id: "reasoning-1",
  147. text: "Think",
  148. providerMetadata: { anthropic: { signature: "sig_1" } },
  149. }),
  150. SessionMessage.AssistantTool.make({
  151. type: "tool",
  152. id: "pending",
  153. name: "read",
  154. state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }),
  155. time: { created },
  156. }),
  157. SessionMessage.AssistantTool.make({
  158. type: "tool",
  159. id: "running",
  160. name: "read",
  161. state: SessionMessage.ToolStateRunning.make({
  162. status: "running",
  163. input: { path: "README.md" },
  164. content: [],
  165. structured: { type: "media", mime: "image/png" },
  166. }),
  167. time: { created },
  168. }),
  169. SessionMessage.AssistantTool.make({
  170. type: "tool",
  171. id: "completed",
  172. name: "read",
  173. state: SessionMessage.ToolStateCompleted.make({
  174. status: "completed",
  175. input: { path: "README.md" },
  176. content: [
  177. { type: "text", text: "Hello" },
  178. {
  179. type: "file",
  180. uri: "data:image/png;base64,aGVsbG8=",
  181. mime: "image/png",
  182. name: "hello.png",
  183. },
  184. ],
  185. structured: {},
  186. }),
  187. time: { created, completed: created },
  188. }),
  189. SessionMessage.AssistantTool.make({
  190. type: "tool",
  191. id: "hosted",
  192. name: "web_search",
  193. provider: {
  194. executed: true,
  195. metadata: { fake: { continuation: "hosted-call" } },
  196. resultMetadata: { fake: { continuation: "hosted-result" } },
  197. },
  198. state: SessionMessage.ToolStateCompleted.make({
  199. status: "completed",
  200. input: { query: "Effect" },
  201. content: [{ type: "text", text: "Found it" }],
  202. structured: {},
  203. }),
  204. time: { created, completed: created },
  205. }),
  206. SessionMessage.AssistantTool.make({
  207. type: "tool",
  208. id: "hosted-failed",
  209. name: "write",
  210. provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
  211. state: SessionMessage.ToolStateError.make({
  212. status: "error",
  213. input: { path: "README.md" },
  214. content: [],
  215. structured: {},
  216. error: { type: "unknown", message: "Denied" },
  217. }),
  218. time: { created, completed: created },
  219. }),
  220. ],
  221. time: { created, completed: created },
  222. }),
  223. ],
  224. model,
  225. )
  226. expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
  227. expect(messages[0]?.content).toEqual([
  228. { type: "text", text: "Checking" },
  229. { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
  230. { type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
  231. { type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
  232. {
  233. type: "tool-call",
  234. id: "completed",
  235. name: "read",
  236. input: { path: "README.md" },
  237. },
  238. {
  239. type: "tool-call",
  240. id: "hosted",
  241. name: "web_search",
  242. input: { query: "Effect" },
  243. providerExecuted: true,
  244. providerMetadata: { fake: { continuation: "hosted-call" } },
  245. },
  246. {
  247. type: "tool-result",
  248. id: "hosted",
  249. name: "web_search",
  250. providerExecuted: true,
  251. providerMetadata: { fake: { continuation: "hosted-result" } },
  252. result: { type: "text", value: "Found it" },
  253. },
  254. {
  255. type: "tool-call",
  256. id: "hosted-failed",
  257. name: "write",
  258. input: { path: "README.md" },
  259. providerExecuted: true,
  260. providerMetadata: { fake: { continuation: "failed" } },
  261. },
  262. {
  263. type: "tool-result",
  264. id: "hosted-failed",
  265. name: "write",
  266. providerExecuted: true,
  267. providerMetadata: { fake: { continuation: "failed" } },
  268. result: {
  269. type: "error",
  270. value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
  271. },
  272. },
  273. ])
  274. expect(messages[1]?.content).toEqual([
  275. {
  276. type: "tool-result",
  277. id: "completed",
  278. name: "read",
  279. result: {
  280. type: "content",
  281. value: [
  282. { type: "text", text: "Hello" },
  283. { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" },
  284. ],
  285. },
  286. },
  287. ])
  288. })
  289. test("restores OpenAI encrypted reasoning metadata", () => {
  290. const messages = toLLMMessages(
  291. [
  292. SessionMessage.Assistant.make({
  293. id: id("assistant-openai-reasoning"),
  294. type: "assistant",
  295. agent: "build",
  296. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  297. content: [
  298. SessionMessage.AssistantReasoning.make({
  299. type: "reasoning",
  300. id: "reasoning-openai",
  301. text: "Think",
  302. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  303. }),
  304. ],
  305. time: { created, completed: created },
  306. }),
  307. ],
  308. model,
  309. )
  310. expect(messages[0]?.content).toEqual([
  311. {
  312. type: "reasoning",
  313. text: "Think",
  314. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  315. },
  316. ])
  317. })
  318. test("drops provider-native continuation metadata from failed assistant turns", () => {
  319. const messages = toLLMMessages(
  320. [
  321. SessionMessage.Assistant.make({
  322. id: id("assistant-failed"),
  323. type: "assistant",
  324. agent: "build",
  325. model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
  326. content: [
  327. SessionMessage.AssistantReasoning.make({
  328. type: "reasoning",
  329. id: "reasoning-failed",
  330. text: "Partial thought",
  331. providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } },
  332. }),
  333. SessionMessage.AssistantTool.make({
  334. type: "tool",
  335. id: "hosted-failed",
  336. name: "web_search",
  337. provider: {
  338. executed: true,
  339. metadata: { openai: { itemId: "call_failed" } },
  340. resultMetadata: { openai: { itemId: "result_failed" } },
  341. },
  342. state: SessionMessage.ToolStateError.make({
  343. status: "error",
  344. input: { query: "Effect" },
  345. error: { type: "unknown", message: "Provider turn interrupted" },
  346. content: [],
  347. structured: {},
  348. }),
  349. time: { created, completed: created },
  350. }),
  351. ],
  352. finish: "error",
  353. error: { type: "unknown", message: "Provider turn interrupted" },
  354. time: { created, completed: created },
  355. }),
  356. ],
  357. model,
  358. )
  359. expect(messages[0]?.content).toEqual([
  360. { type: "reasoning", text: "Partial thought", providerMetadata: undefined },
  361. {
  362. type: "tool-call",
  363. id: "hosted-failed",
  364. name: "web_search",
  365. input: { query: "Effect" },
  366. providerExecuted: true,
  367. providerMetadata: undefined,
  368. },
  369. {
  370. type: "tool-result",
  371. id: "hosted-failed",
  372. name: "web_search",
  373. result: {
  374. type: "error",
  375. value: {
  376. error: { type: "unknown", message: "Provider turn interrupted" },
  377. content: [],
  378. structured: {},
  379. },
  380. },
  381. providerExecuted: true,
  382. cache: undefined,
  383. metadata: undefined,
  384. providerMetadata: undefined,
  385. },
  386. ])
  387. })
  388. test("drops provider-native continuation metadata after a model switch", () => {
  389. const messages = toLLMMessages(
  390. [
  391. SessionMessage.Assistant.make({
  392. id: id("assistant-old-model"),
  393. type: "assistant",
  394. agent: "build",
  395. model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
  396. content: [
  397. SessionMessage.AssistantReasoning.make({
  398. type: "reasoning",
  399. id: "reasoning-old-model",
  400. text: "Visible thought",
  401. providerMetadata: { anthropic: { signature: "sig_old" } },
  402. }),
  403. SessionMessage.AssistantTool.make({
  404. type: "tool",
  405. id: "hosted-old-model",
  406. name: "web_search",
  407. provider: {
  408. executed: true,
  409. metadata: { openai: { itemId: "hosted-old-model" } },
  410. resultMetadata: { openai: { itemId: "hosted-old-model" } },
  411. },
  412. state: SessionMessage.ToolStateCompleted.make({
  413. status: "completed",
  414. input: { query: "Effect" },
  415. content: [],
  416. structured: {},
  417. result: { type: "json", value: { status: "completed" } },
  418. }),
  419. time: { created, completed: created },
  420. }),
  421. SessionMessage.AssistantTool.make({
  422. type: "tool",
  423. id: "local-old-model",
  424. name: "read",
  425. provider: {
  426. executed: false,
  427. metadata: { fake: { call: "old" } },
  428. resultMetadata: { fake: { result: "old" } },
  429. },
  430. state: SessionMessage.ToolStateCompleted.make({
  431. status: "completed",
  432. input: { path: "README.md" },
  433. content: [],
  434. structured: { text: "Hello" },
  435. }),
  436. time: { created, completed: created },
  437. }),
  438. ],
  439. time: { created, completed: created },
  440. }),
  441. ],
  442. model,
  443. )
  444. expect(messages[0]?.content).toEqual([
  445. { type: "text", text: "Visible thought" },
  446. {
  447. type: "tool-call",
  448. id: "hosted-old-model",
  449. name: "web_search",
  450. input: { query: "Effect" },
  451. providerExecuted: true,
  452. providerMetadata: undefined,
  453. },
  454. {
  455. type: "tool-result",
  456. id: "hosted-old-model",
  457. name: "web_search",
  458. result: { type: "json", value: { status: "completed" } },
  459. providerExecuted: true,
  460. cache: undefined,
  461. metadata: undefined,
  462. providerMetadata: undefined,
  463. },
  464. {
  465. type: "tool-call",
  466. id: "local-old-model",
  467. name: "read",
  468. input: { path: "README.md" },
  469. providerExecuted: false,
  470. providerMetadata: undefined,
  471. },
  472. ])
  473. expect(messages[1]?.content).toEqual([
  474. {
  475. type: "tool-result",
  476. id: "local-old-model",
  477. name: "read",
  478. result: { type: "json", value: { text: "Hello" } },
  479. providerExecuted: false,
  480. cache: undefined,
  481. metadata: undefined,
  482. providerMetadata: undefined,
  483. },
  484. ])
  485. })
  486. })